Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 13 additions & 2 deletions bukkit/example-plugin/src/main/java/com/example/ExamplePlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,26 @@
import dev.faststats.core.data.Metric;
import org.bukkit.plugin.java.JavaPlugin;

import java.lang.reflect.InvocationTargetException;
import java.net.URI;
import java.nio.file.AccessDeniedException;
import java.util.concurrent.atomic.AtomicInteger;

public class ExamplePlugin extends JavaPlugin {
// context-aware error tracker, automatically tracks errors in the same class loader
public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware();
public static final ErrorTracker ERROR_TRACKER = ErrorTracker.contextAware()
// Ignore specific errors and messages
.ignoreError(InvocationTargetException.class, "Expected .* but got .*") // Ignored an error with a message
.ignoreError(AccessDeniedException.class); // Ignored a specific error type

// context-unaware error tracker, does not automatically track errors
public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware();
public static final ErrorTracker CONTEXT_UNAWARE_ERROR_TRACKER = ErrorTracker.contextUnaware()
// Anonymize error messages if required
.anonymize("^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$", "[email hidden]") // Email addresses
.anonymize("Bearer [A-Za-z0-9._~+/=-]+", "Bearer [token hidden]") // Bearer tokens in error messages
.anonymize("AKIA[0-9A-Z]{16}", "[aws-key hidden]") // AWS access key IDs
.anonymize("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[uuid hidden]") // UUIDs (e.g. session/user IDs)
.anonymize("([?&](?:api_?key|token|secret)=)[^&\\s]+", "$1[redacted]"); // API keys in query strings

private final AtomicInteger gameCount = new AtomicInteger();

Expand Down
93 changes: 56 additions & 37 deletions core/src/main/java/dev/faststats/core/ErrorHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;

Expand All @@ -17,9 +19,10 @@ final class ErrorHelper {
private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300));
private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15));

public static JsonObject compile(final Throwable error, @Nullable final List<String> suppress, final boolean handled) {
public static JsonObject compile(final Throwable error, @Nullable final List<String> suppress, final boolean handled,
final List<Map.Entry<Pattern, String>> customPatterns) {
final var report = new JsonObject();
final var message = getAnonymizedMessage(error);
final var message = getAnonymizedMessage(error, customPatterns);

final var stacktrace = new JsonArray();
final var header = message != null
Expand All @@ -34,7 +37,7 @@ public static JsonObject compile(final Throwable error, @Nullable final List<Str
final var traces = Math.min(list.size(), STACK_TRACE_LIMIT);

populateTraces(traces, list, elements, stacktrace);
appendCauseChain(error.getCause(), stack, suppress, stacktrace);
appendCauseChain(error.getCause(), stack, suppress, stacktrace, customPatterns);

report.addProperty("error", error.getClass().getName());
if (message != null) report.addProperty("message", message);
Expand All @@ -46,12 +49,13 @@ public static JsonObject compile(final Throwable error, @Nullable final List<Str
}

private static void appendCauseChain(@Nullable Throwable cause, final List<String> parentStack,
@Nullable final List<String> suppress, final JsonArray stacktrace) {
@Nullable final List<String> suppress, final JsonArray stacktrace,
final List<Map.Entry<Pattern, String>> customPatterns) {
final var toSuppress = new ArrayList<>(parentStack);
if (suppress != null) toSuppress.addAll(suppress);
final var visited = Collections.<Throwable>newSetFromMap(new IdentityHashMap<>());
while (cause != null && visited.add(cause)) {
final var causeMessage = getAnonymizedMessage(cause);
final var causeMessage = getAnonymizedMessage(cause, customPatterns);
final var header = causeMessage != null
? "Caused by: " + cause.getClass().getName() + ": " + causeMessage
: "Caused by: " + cause.getClass().getName();
Expand All @@ -68,7 +72,8 @@ private static void appendCauseChain(@Nullable Throwable cause, final List<Strin
}
}

private static void populateTraces(final int traces, final List<String> list, final StackTraceElement[] elements, final JsonArray stacktrace) {
private static void populateTraces(final int traces, final List<String> list, final StackTraceElement[] elements,
final JsonArray stacktrace) {
for (var i = 0; i < traces; i++) {
final var string = list.get(i);
if (string.length() <= STACK_TRACE_LENGTH) stacktrace.add(" at " + string);
Expand Down Expand Up @@ -188,40 +193,54 @@ private static boolean isSameClassLoader(final ClassLoader classLoader, final Cl
return loader == current;
}

private static final Pattern IPV4_PATTERN = Pattern.compile(
"\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b");
private static final Pattern IPV6_PATTERN = Pattern.compile(
"(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form
"(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing ::
"(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after)
"(?i)\\b([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\\b|" + // :: in middle (2 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\\b|" + // :: in middle (3 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\\b|" + // :: in middle (4 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\\b|" + // :: in middle (5 groups after)
"(?i)\\b[0-9a-f]{1,4}:(:[0-9a-f]{1,4}){1,6}\\b|" + // :: in middle (6 groups after)
"(?i)\\b:(:[0-9a-f]{1,4}){1,7}\\b|" + // Leading ::
"(?i)\\b::([0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\\b|" + // :: at start
"(?i)\\b::\\b"); // Just ::
private static final Pattern USER_HOME_PATH_PATTERN = Pattern.compile(
"(/home/)[^/\\s]+" + // Linux: /home/username
"|(/Users/)[^/\\s]+" + // macOS: /Users/username
"|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username

private static String anonymize(String message) {
message = IPV4_PATTERN.matcher(message).replaceAll("[IP hidden]");
message = IPV6_PATTERN.matcher(message).replaceAll("[IP hidden]");
message = USER_HOME_PATH_PATTERN.matcher(message).replaceAll("$1$2$3[username hidden]");
final var username = System.getProperty("user.name");
if (username != null) message = message.replace(username, "[username hidden]");
return message;
}

private static @Nullable String getAnonymizedMessage(final Throwable error) {
private static @Nullable String getAnonymizedMessage(final Throwable error, final List<Map.Entry<Pattern, String>> customPatterns) {
final var message = error.getMessage();
if (message == null) return null;
final var truncated = message.length() > MESSAGE_LENGTH
var truncated = message.length() > MESSAGE_LENGTH
? message.substring(0, MESSAGE_LENGTH) + "..."
: message;
return anonymize(truncated);
for (final var entry : customPatterns) {
truncated = entry.getKey().matcher(truncated).replaceAll(entry.getValue());
}
return truncated;
}

public static Pattern discordWebhookPattern() {
return Pattern.compile("(https://discord\\.com/api/webhooks/\\d+/)[\\w-]+");
}

public static Pattern ipv4Pattern() {
return Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b");
}

public static Pattern ipv6Pattern() {
return Pattern.compile("(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form
"(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing ::
"(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after)
"(?i)\\b([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\\b|" + // :: in middle (2 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\\b|" + // :: in middle (3 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\\b|" + // :: in middle (4 groups after)
"(?i)\\b([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\\b|" + // :: in middle (5 groups after)
"(?i)\\b[0-9a-f]{1,4}:(:[0-9a-f]{1,4}){1,6}\\b|" + // :: in middle (6 groups after)
"(?i)\\b:(:[0-9a-f]{1,4}){1,7}\\b|" + // Leading ::
"(?i)\\b::([0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\\b|" + // :: at start
"(?i)\\b::\\b"); // Just ::
}

public static Pattern jdbcUrlPattern() {
return Pattern.compile("(jdbc:[^:]+://[^:]+:(?:\\d+:)?)[^@]+(@)");
}

public static Pattern userHomePathPattern() {
return Pattern.compile("(/home/)[^/\\s]+" + // Linux: /home/username
"|(/Users/)[^/\\s]+" + // macOS: /Users/username
"|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"); // Windows: A-Z:\\Users\\username
}

public static Optional<Pattern> usernamePattern() {
return Optional.ofNullable(System.getProperty("user.name"))
.filter(s -> s.trim().length() > 2)
.map(Pattern::quote)
.map(Pattern::compile);
}
}
34 changes: 32 additions & 2 deletions core/src/main/java/dev/faststats/core/ErrorTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ static ErrorTracker contextUnaware() {
*
* @param type the error type
* @return the error tracker
* @since 0.21.0
* @since 0.22.0
*/
@Contract(value = "_ -> this", mutates = "this")
ErrorTracker ignoreErrorType(Class<? extends Throwable> type);
ErrorTracker ignoreError(Class<? extends Throwable> type);

/**
* Adds a pattern that will be matched against all error messages.
Expand Down Expand Up @@ -177,6 +177,36 @@ default ErrorTracker ignoreError(final Class<? extends Throwable> type, @RegExp
return ignoreError(type, Pattern.compile(pattern));
}

/**
* Adds an anonymization pattern that replaces matched text in error messages.
* <pre>{@code
* tracker.anonymize(Pattern.compile("token=[^&]+"), "token=[redacted]");
* }</pre>
*
* @param pattern the regex pattern to match
* @param replacement the replacement string
* @return the error tracker
* @see java.util.regex.Matcher#replaceAll(String)
* @since 0.22.0
*/
@Contract(value = "_, _ -> this", mutates = "this")
ErrorTracker anonymize(Pattern pattern, String replacement);

/**
* Adds an anonymization pattern that replaces matched text in error messages.
*
* @param pattern the regex pattern string to match
* @param replacement the replacement string
* @return the error tracker
* @see #anonymize(Pattern, String)
* @see java.util.regex.Matcher#replaceAll(String)
* @since 0.22.0
*/
@Contract(value = "_, _ -> this", mutates = "this")
default ErrorTracker anonymize(@RegExp final String pattern, final String replacement) {
return anonymize(Pattern.compile(pattern), replacement);
}

/**
* Attaches an error context to the tracker.
* <p>
Expand Down
23 changes: 21 additions & 2 deletions core/src/main/java/dev/faststats/core/SimpleErrorTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
import java.lang.Thread.UncaughtExceptionHandler;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
Expand All @@ -23,10 +25,21 @@ final class SimpleErrorTracker implements ErrorTracker {
private final Map<Class<? extends Throwable>, Set<Pattern>> ignoredTypedPatterns = new ConcurrentHashMap<>();
private final Set<Class<? extends Throwable>> ignoredTypes = new CopyOnWriteArraySet<>();
private final Set<Pattern> ignoredPatterns = new CopyOnWriteArraySet<>();
private final List<Map.Entry<Pattern, String>> anonymizationEntries = new CopyOnWriteArrayList<>(List.of(
Map.entry(ErrorHelper.ipv4Pattern(), "[IP hidden]"),
Map.entry(ErrorHelper.ipv6Pattern(), "[IP hidden]"),
Map.entry(ErrorHelper.userHomePathPattern(), "$1$2$3[username hidden]"),
Map.entry(ErrorHelper.discordWebhookPattern(), "$1[token hidden]"),
Map.entry(ErrorHelper.jdbcUrlPattern(), "$1[password hidden]$2")
));

private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent = null;
private volatile @Nullable UncaughtExceptionHandler originalHandler = null;

public SimpleErrorTracker() {
ErrorHelper.usernamePattern().ifPresent(pattern -> anonymizationEntries.add(Map.entry(pattern, "[username hidden]")));
}

@Override
public void trackError(final String message) {
trackError(message, true);
Expand All @@ -46,7 +59,7 @@ public void trackError(final String message, final boolean handled) {
public void trackError(final Throwable error, final boolean handled) {
try {
if (isIgnored(error, Collections.newSetFromMap(new IdentityHashMap<>()))) return;
final var compiled = ErrorHelper.compile(error, null, handled);
final var compiled = ErrorHelper.compile(error, null, handled, anonymizationEntries);
final var hashed = MurmurHash3.hash(compiled);
if (collected.compute(hashed, (k, v) -> {
return v == null ? 1 : v + 1;
Expand All @@ -72,7 +85,7 @@ private boolean isIgnored(@Nullable final Throwable error, final Set<Throwable>
}

@Override
public ErrorTracker ignoreErrorType(final Class<? extends Throwable> type) {
public ErrorTracker ignoreError(final Class<? extends Throwable> type) {
ignoredTypes.add(type);
return this;
}
Expand All @@ -89,6 +102,12 @@ public ErrorTracker ignoreError(final Class<? extends Throwable> type, final Pat
return this;
}

@Override
public ErrorTracker anonymize(final Pattern pattern, final String replacement) {
anonymizationEntries.add(Map.entry(pattern, replacement));
return this;
}

public JsonArray getData(final String buildId) {
final var report = new JsonArray(reports.size());

Expand Down
Loading