Apache Camel's built-in masking can sometimes truncate after the first match. This article shows how to implement a custom MaskingFormatter that processes entire strings and masks all sensitive fields in JSON, query strings, and mixed formats without truncation.

Why Custom Masking?

The custom formatter:

  • Uses SensitiveUtils.getSensitiveKeys() (~100 built-in keywords like password, secret, token)
  • Adds your custom keywords (userId, ssn, creditCard)
  • Handles both JSON ("key": "value") and query strings (key=value)
  • Uses Matcher.appendReplacement() loop to mask every occurrence
  • Never truncates - processes complete input

Example

import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.spi.MaskingFormatter;
import org.apache.camel.support.SimpleRegistry;
import org.apache.camel.util.SensitiveUtils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Custom formatter that masks ALL sensitive fields without truncating.
 * Processes entire string and masks all occurrences of sensitive keywords.
 */
static class NonTruncatingMaskingFormatter implements MaskingFormatter {
    private final Pattern sensitivePattern;

    public NonTruncatingMaskingFormatter() {
        // Use SensitiveUtils for ~100 built-in keywords + custom ones
        var sensitiveKeys = new java.util.HashSet<>(SensitiveUtils.getSensitiveKeys());
        sensitiveKeys.add("userId");
        sensitiveKeys.add("ssn");
        sensitiveKeys.add("creditCard");

        String keywordGroup = String.join("|", sensitiveKeys);

        // Matches JSON: "keyword": "value" AND query: keyword=value
        this.sensitivePattern = Pattern.compile(
                "(?i)(?:([\"'])(" + keywordGroup + ")\\1\\s*:\\s*[\"']([^\"']+)[\"']" +  // JSON
                        "|(" + keywordGroup + ")\\s*=\\s*([^&\\s,}]+))",  // Query string
                Pattern.CASE_INSENSITIVE
        );
    }

    @Override
    public String format(String source) {
        if (source == null || source.isEmpty()) {
            return source;
        }

        Matcher matcher = sensitivePattern.matcher(source);
        StringBuilder result = new StringBuilder();

        while (matcher.find()) {
            String replacement;
            if (matcher.group(1) != null) {
                // JSON format: "key": "value" -> "key": "xxxxx"
                String quote = matcher.group(1);
                String keyword = matcher.group(2);
                replacement = quote + keyword + quote + ": \"xxxxx\"";
            } else {
                // Query string: key=value -> key=xxxxx
                String keyword = matcher.group(4);
                replacement = keyword + "=xxxxx";
            }
            matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
        }
        matcher.appendTail(result);

        return result.toString();
    }
}

static class MyRoutes extends RouteBuilder {
    @Override
    public void configure() {
        from("direct:test")
                .to("log:maskedLogger?showAll=true&multiline=true")
                .log("*** FULL BODY (custom masking): ${body}");
    }
}

void main() throws Exception {
    SimpleRegistry registry = new SimpleRegistry();

    // Register custom formatter
    NonTruncatingMaskingFormatter formatter = new NonTruncatingMaskingFormatter();
    registry.bind(MaskingFormatter.CUSTOM_LOG_MASK_REF, formatter);

    DefaultCamelContext context = new DefaultCamelContext(registry);
    context.setLogMask(true);  // Enable globally
    context.addRoutes(new MyRoutes());
    context.start();

    // Query string payload - ALL fields masked
    String queryPayload = "userId=12345&password=pass123&apiKey=abc-xyz-123&ssn=123-45-6789&creditCard=4111-1111-1111-1111&token=jwt.abc.def&anotherPassword=secret456";
    context.createProducerTemplate().sendBody("direct:test", queryPayload);

    // Complex nested JSON - ALL fields masked
    String jsonPayload = """
        {
            "userId": "user123",
            "username": "john.doe",
            "password": "secretPass456",
            "email": "john@example.com",
            "apiKey": "sk-live-abc123xyz",
            "ssn": "987-65-4321",
            "creditCard": "5555-4444-3333-2222",
            "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
            "profile": {
                "accessToken": "ghp_xxxxxxxxxxxx",
                "refreshToken": "refresh_abc123"
            }
        }
        """;
    context.createProducerTemplate().sendBody("direct:test", jsonPayload);

    Thread.sleep(2000);
    context.stop();
}

Expected Output

Query string (ALL 7 fields masked):

*** FULL BODY (custom masking): userId=xxxxx&password=xxxxx&apiKey=xxxxx&ssn=xxxxx&creditCard=xxxxx&token=xxxxx&anotherPassword=xxxxx

JSON (ALL 10+ fields masked):

*** FULL BODY (custom masking): {
    "userId": "xxxxx",
    "username": "xxxxx",
    "password": "xxxxx",
    "email": "john@example.com",
    "apiKey": "xxxxx",
    "ssn": "xxxxx",
    "creditCard": "xxxxx",
    "token": "xxxxx",
    "profile": {
        "accessToken": "xxxxx",
        "refreshToken": "xxxxx"
    }
}

Key Advantages

Feature Built-in Custom
Truncation ❌ Sometimes ✅ Never
Custom keywords Limited ✅ Full control
Nested objects ❌ Limited ✅ Complete scan

Usage Patterns

Route-level:

from("direct:secure")
    .logMask()  // Uses your custom formatter
    .log("${body}");

Endpoint-level:

.to("log:secure?logMask=true")

Global: context.setLogMask(true) (as shown)