spring-boot-gdpr

GDPR-compliant logging in Spring Boot, part 2: a build-time test that fails on personal data in log statements

Rigas PapazisisSoftware Engineering

In part 1 we listed the rules that keep personal data out of a log line: no record or DTO handed to {}, no value in an exception message, an internal account id for the actor. Rules like these are rarely new to a team. They are usually written down, and applied with care wherever their author happened to be looking.

A rule that lives in prose holds for the cases its author had in mind. This post is about making it hold everywhere else: a plain JUnit test that reads every main source file and fails the build when a log statement is handed personal data by name. It is about 500 lines including its javadoc, and it has no dependencies beyond JUnit and AssertJ. Unlike a human audit, it does not stop after the cases it already knows about. Part 3 covers what such a test cannot see.

Tested with: Java 21, Spring Boot 4.1.0, JUnit Jupiter 6.0.3 and AssertJ 3.27.7 (both managed by the Spring Boot BOM), Logback 1.5.34, in a Maven multi-module build.

Before the test: audit once, and count

Read every log statement once. Not a sample: all of them. Grep gets you the list and the high-yield candidates first:

# every log statement in main sources
grep -rn --include=*.java -E "\b(log|logger|LOG)\.(trace|debug|info|warn|error)\(" src/main/java | wc -l

# accessors that name personal data
grep -rnE "\b(log|logger|LOG)\.[a-z]+\([^)]*(getEmail|getPhone|getName|getAddress|getIban)" src/main/java

Record the total and the findings, split by whether each leak is live at the default log levels. A count of findings against the total, with how many are live at default levels, is a sentence a DPO can use and a team can re-measure. Then fix by pattern, not by instance, which is the lesson the next section is built on.

The scanner

The shape is simple. Find every log call, extract the whole statement with balanced parentheses (statements wrap across lines, and arguments contain parentheses and string literals), strip what is not code, and match the rest against patterns that name personal data:

class LogStatementGdprScannerTest {

    private static final Pattern LOG_CALL = Pattern.compile(
            "\\b(log|logger|LOG|LOGGER)\\s*\\.\\s*(trace|debug|info|warn|error)\\s*\\(");

    /** What personal data looks like BY NAME in an argument list. */
    private static final Map<String, Pattern> FORBIDDEN = new LinkedHashMap<>();
    static {
        FORBIDDEN.put("an e-mail address", Pattern.compile(
                "(?i)\\b\\w*\\.(get)?(e?mail|emailAddress)\\s*\\(\\)|(?i)\\b(email|emailAddress)\\b\\s*[,)]"));
        FORBIDDEN.put("a national ID", Pattern.compile(
                "(?i)\\b\\w*\\.(get)?(nationalId)\\s*\\(\\)|(?i)\\b(nationalId)\\b\\s*[,)]"));
        FORBIDDEN.put("a person's name", Pattern.compile(
                "(?i)\\b\\w*\\.(get)?(firstName|lastName|fullName|displayName)\\s*\\(\\)"));
        FORBIDDEN.put("a token subject", Pattern.compile("\\bjwt\\s*\\.\\s*getSubject\\s*\\(\\)"));
        // Rule 1: a whole object, whose toString() prints every component
        FORBIDDEN.put("a whole DTO/record/entity", Pattern.compile(
                "[,(]\\s*(\\w*(?:Dto|Filter|Payload|RequestBody|Entity))\\s*[,)]"
                        + "|[,(]\\s*(filter|dto|payload|body|customer)\\s*[,)]"));
        // ... phone, address, IBAN, message subject/body
    }

    /** The opt-out marker: exempts a statement only when it gives a reason. Checked against the raw text, comments included. */
    private static final Pattern OPT_OUT = Pattern.compile("GDPR-OK:[ \\t]*\\S");

    /** Approved sanitisers: the call and its argument are removed before matching; nothing else on the line is exempted. */
    private static final Pattern SANITISER_CALL = Pattern.compile("\\btaxIdForLog\\s*\\((?:[^()]|\\([^()]*\\))*\\)");

    @Test
    void noLogStatementCarriesPersonalData() {
        List<String> violations = new ArrayList<>();
        for (Path file : allMainSources()) {
            String content = read(file);
            for (String statement : logStatementsIn(content)) {   // paren-balanced, multi-line
                if (OPT_OUT.matcher(statement).find()) {
                    continue;
                }
                // literals and comments blanked, presence checks and sanitiser calls removed
                String code = SANITISER_CALL.matcher(codeOnly(statement)).replaceAll("_");
                FORBIDDEN.forEach((kind, pattern) -> {
                    if (pattern.matcher(code).find()) {
                        violations.add(file + ":" + lineOf(content, statement) + " logs " + kind);
                    }
                });
            }
        }
        assertThat(violations).isEmpty();
    }
}

Notice what it does not try to do. It does not try to recognise a value. It recognises a name: an accessor or a variable whose name says it holds personal data. That is less clever than it sounds, and that is the point: it runs in milliseconds, it never needs a running application, and every failure message points at a file and a line.

The failure message matters more than it seems. Ours says what was found, why it matters (“a value written there cannot be erased”), and what to write instead: an account id, an entity id, a count, or the identifier helper. A developer who hits it at 6 pm should be able to fix it without finding anyone.

Scan the whole repository from the module that builds first

In a multi-module build, put the test in the module that builds first and have it walk up to the aggregator root and scan every module’s src/main/java. The alternatives are worse: one copy per module to keep in sync, or a published test-jar for one test. A violation anywhere then fails the build early. The cost is that a violation in an application module is reported by a library module’s build, which surprises people exactly once, because the message names the real file.

Test sources are excluded on purpose: assess your fixtures once, confirm they are synthetic, write that down, and you do not need to scan them.

The test that proves the test ran

A scanner that silently covers zero files is worse than no scanner: it passes forever and reports safety. So it gets a guard of its own:

@Test
void scansTheWholeReactor() {
    List<Path> sources = allMainSources();
    // Loose enough not to be maintenance noise, tight enough that a broken path fails loudly.
    assertThat(sources).hasSizeGreaterThan(200);
    assertThat(sources.stream().map(this::moduleOf).distinct())
            .contains("example-core", "example-domain", "example-orders-api", "example-billing-api");
}

Here is one concrete way to scan nothing: locate the root by looking for a pom.xml naming the aggregator’s artifactId. Every child pom names the aggregator in its <parent> block, so the search stops in whichever module the test happens to run from, and the scan covers nothing at all while the test stays green. Identify the root by its <modules> list instead, and throw if you cannot find it rather than guessing.

A third test feeds the scanner synthetic statements, one per pattern, plus the known-good ones that must stay green. It guards against the quieter failure: a regex that is broken, so that the suite goes green on everything.

@Test
void detectsWhatItClaimsTo() {
    assertThat(firstKindFor("log.info(\"actor={}\", user.getEmail());")).isEqualTo("an e-mail address");
    assertThat(firstKindFor("log.info(\"search {}\", filter);")).isEqualTo("a whole DTO/record/entity");
    // ...and the correct idioms stay clean
    assertThat(firstKindFor("log.info(\"actor={}\", userAccountId);")).isNull();
    assertThat(firstKindFor("log.debug(\"byNationalId={}\", filter.nationalId() != null);")).isNull();
    // ...and the escape hatches exempt no more than they claim to
    assertThat(firstKindFor("log.warn(\"supplier={} by {}\", taxIdForLog(s.getTaxId()), user.getEmail());"))
            .isEqualTo("an e-mail address");
    assertThat(firstKindFor("// GDPR-OK:\nlog.info(\"actor={}\", user.getEmail());")).isEqualTo("an e-mail address");
}

An opt-out marker that requires a reason

Some mentions are genuinely safe, and the scanner cannot always tell. Give it a documented escape hatch, in a comment on or directly above the statement, and require a reason:

// GDPR-OK: recipient count only, no address
log.info("Notification {} sent to {} recipients", notificationId, recipients.size());

A marker with a reason records a decision; a bare marker mutes one. So the scanner itself enforces the reason: GDPR-OK: followed by nothing exempts nothing. Reviewers then have a single string to grep for, and each hit explains itself.

Approved sanitisers are handled differently, and the difference matters. It is tempting to put the helper’s name in the same allow-list as the marker, so that any statement calling it is skipped. Do not: that exempts the whole statement, so a line that passes one identifier through the helper and prints an e-mail raw beside it goes green. Remove the helper call, with its argument, from the code before matching instead. The sanitised value disappears; everything else on the line is still checked. The same goes for a word in a message: an allow-list entry such as “withheld”, meant to recognise lines that say they withheld something, exempts every statement that merely contains the word.

Pitfalls: the first run will cry wolf

Expect the first run to be mostly false positives. A scanner that cries wolf gets deleted, so budget a tuning pass and pin each fix with a case in the self-test. Every false positive fell into one of these four causes:

  1. String literals. A format string names the field it describes. "byNationalId={}" is a presence flag; "payload names no customer ID, refusing" is an error message. Neither is a value. Blank the contents of every literal before matching, keeping the quotes so argument positions still parse.
  2. Comments. The most conscientious code trips a naive scanner first: // never log the filter itself, toString() prints the national ID sits right above the correctly written statement. Strip comments before matching, but look for the GDPR-OK marker in the raw text, because it lives in a comment by design.
  3. The preceding line of code. To see a marker above a statement, it is tempting to grab the previous two lines. Do not: updateItems(body); on the line above then reads as “logs a whole DTO”. Attach only the run of comment and blank lines directly above. This cause alone can produce most of a first run’s false positives.
  4. Presence checks. filter.nationalId() != null is a boolean, and it is exactly the idiom part 1 tells people to write. Strip the construct (!= null, isBlank(), size(), length()) before matching rather than adding the accessor to the allow-list. An allow-list entry exempts the whole statement, so a line that presence-checks one field and prints another raw would pass.

The hits that survive tuning tend to be exactly the class of bug a manual fix-up misses: a further search-filter record, with a field nobody thought of as personal data, passed whole to {}. A human audit stops at the instances it found; the scanner does not stop.

Two more limits to state plainly in the test’s javadoc. It cannot catch a variable named x that holds an e-mail; naming discipline is part of the contract. And it cannot see any value that reaches a log from outside your source, which is the whole subject of part 3. Do not widen the patterns hoping to catch those; the values are not in the source.

Why we decided against runtime masking

The obvious next step is a backstop: a filter that masks identifier-shaped values on their way out. We evaluated one seriously and decided against it. Here is the reasoning, because the dead end is attractive and every team is tempted by it.

A Logback TurboFilter cannot rewrite a message

The first instinct is a TurboFilter, because it sees the format and the arguments before the event is built. But its contract returns ACCEPT, DENY or NEUTRAL; it is a gate, not a transformer. Mutating the argument array it receives does not help either. In Logback 1.5, the one- and two-argument logger methods, which are most calls, pass the turbo filter chain the arguments and then build the event from a fresh array:

// ch.qos.logback.classic.Logger (1.5.x), abridged
private void filterAndLog_1(String fqcn, Marker marker, Level level, String msg, Object param, Throwable t) {
    FilterReply decision = loggerContext.getTurboFilterChainDecision_1(marker, this, level, msg, param, t);
    // ... level and decision checks
    buildLoggingEventAndAppend(fqcn, marker, level, msg, new Object[] { param }, t);
}

Whatever the filter did to its copy is silently lost. The only place a message can genuinely be rewritten is an appender decorator. A %replace in the pattern layout is trivial, but it only affects appenders that use a layout: it protects stdout and leaves an OpenTelemetry collector untouched, which is usually the wrong way round.

Four reasons the decorator went too

  1. The scanner already closes the hole at its source, and better. A masked line is a leak that shipped and was partly hidden; a failed build is a leak that never shipped.
  2. It cannot catch a name. A person’s name has no distinguishing shape, so no regex finds it. Names always depended on the call site.
  3. A blanket pattern damages diagnostics. “Thirteen consecutive digits” also eats epoch-millisecond timestamps and long surrogate ids. A backstop that ruins ordinary debugging is one somebody switches off.
  4. Where it uniquely helps, it defeats the switch. The one thing only an output filter reaches is a value formatted by a framework, such as Hibernate’s bound parameters. But the only reason anyone raises that logger is to see the real values. Masking them turns an emergency diagnostic into a useless one.

And one hazard if you build one anyway: if your decorator throws, Logback’s appender base catches the exception, reports it to its internal status, and the event is gone. A safety component that silently deletes log lines is worse than none. Make it unable to throw, and test that a broken probe still delivers the message.

There is also a design trap that applies whether or not you mask: redaction must not share code with validation. A masking pattern borrowed from your validator gets tightened the day someone adds a checksum, and every value the validator now rejects stops being masked. Part 1 makes the same point for the call-site helper; it applies doubly to anything global.

We wrote the rejection down with its reasons, including “if this is ever revisited, do not build it as a TurboFilter“, so the dead end is not walked twice.

Conclusion

A few hundred lines of JUnit turn a policy that holds where its author was looking into one that holds in every module. Make it prove it scanned something, make the escape hatch require a reason, budget a tuning pass, and resist the runtime backstop: a build that fails is strictly earlier than a line that is masked. What the scanner cannot see, values that never pass through your source, is where part 3 picks up.

Cheers!

Related sources

Rigas Papazisis