Lookahead y lookbehind en expresiones regulares con Java

Escrito por el .
java planeta-codigo programacion
Comentarios

Java

Las expresiones regulares son un gran invento y muy útil para comprobar que una cadena cumple el patrón definido en la expresión regular, hay coincidencias en partes de la cadena y para reemplazar coincidencias. Son muy potentes para realizar estas tareas pero al mismo tiempo pueden volverse en cierta medida complicadas.

Una de las funcionalidades que soportan las cadenas es búsqueda hacia delante o lookahead y búsqueda hacia atrás o lookbehind. La primera permite examinar los siguientes caracteres de la cadena analizada y la segunda los caracteres pasados.

Hay diferentes formas de lookahead, en Java la construcción que permite hacer lookahead es (?=X) donde X es la expresión siguiente, se puede negar la expresión en el caso de querer que no se cumpla X con (?!X). También existe lookbehind con la construcción (?<=X) para negar que no se cumpla X se ha de emplear (?<!X), como su nombre sugiere en vez de mirar hacia adelante mira hacia atrás en los caracteres ya analizados.

Una aplicación práctica en la que usar lookahead es para ocultar los números de una tarjeta de crédito, una cuenta bancaria o un bearer token de un API REST excepto los cuatro últimos caracteres, este podría ser el caso de que esta información sea incluida en los archivos de log de una aplicación que por seguridad es recomendable ocultar. En el artículo Ofuscar datos sensibles en las trazas con Log4j comento varias formas de hacerlo.

Una tarjeta de crédito está formada por 4 grupos de 4 dígitos separados por espacios cumpliendo la expresión regular \d{4} \d{4} \d{4} \d{4} y un bearer token puede seguir la expresión regular Bearer \w+. Para ocultar la información de estas cadenas excepto los cuatro últimos caracteres hay que comprobar que los primeros complen el patrón añadiéndolos en un grupo de captura para su reemplazo posterior y mirar los cuatro siguientes si también lo cumplen fuera del grupo de captura. En el caso de la tarjeta de crédito se mira que la expresión cumple los primeros números de una tarjeta de crédito y le siguen los restantes, la primera parte se incluye en un grupo de captura con los paréntesis.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package io.github.picodotdev.blogbitix.log4j;

...

public class Main {

    private static final Logger logger = LogManager.getLogger(Main.class);

    public static void main(String[] args) {
        ...

        logger.info(new SecuredMessage("Tarjeta de crédito: 1111 1111 1111 1111, DNI: 11111111A", Arrays.asList("(\\d{4} \\d{4} \\d{4} \\d{1})(?=\\d{3})", "(\\d{6})(?=\\d{2}[A-Z])")));
        logger.info("Tarjeta de crédito: 1111 1111 1111 1111, DNI: 11111111A");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package io.github.picodotdev.blogbitix.log4j;

import org.apache.logging.log4j.message.Message;

import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class SecuredMessage implements Message {

    private static final int UNMASKED_CHARACTERS = 3;

    private Message message;
    private String string;
    private Pattern pattern;

    public SecuredMessage(Message message, Collection<String> patterns) {
        this.message = message;
        this.pattern = compilePatterns(patterns);
    }

    public SecuredMessage(String string, Collection<String> patterns) {
        this.string = string;
        this.pattern = compilePatterns(patterns);
    }

    ...

    @Override
    public String getFormattedMessage() {
        return securedMessage();
    }

    private String securedMessage() {
        if (message != null) {
            return securedMessage(message);
        } else if (string != null) {
            return securedString(string);
        }
        return "";
    }

    private Pattern compilePatterns(Collection<String> patterns) {
        return Pattern.compile(patterns.stream().map(it -> "(" + it + ")").collect(Collectors.joining("|")));
    }

    private String securedMessage(Message message) {
        return securedString(message.getFormattedMessage());
    }

    private String securedString(String string) {
        String result = string;
        Matcher matcher = pattern.matcher(string);
        while (matcher.find()) {
            String match = matcher.group();
            String mask = mask(match);
            result = result.replaceFirst(match, mask);
        }
        return result;
    }

    private String mask(String string) {
        return string.replaceAll(".", "*");
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
configuration:
  status: warn

  appenders:
    console:
      name: STDOUT
      patternLayout:
        pattern: "%d{DEFAULT} %X{uuid} %-5level %60.60logger %msg%n"
        replace: 
          regex: "(\\d{4} \\d{4} \\d{4} \\d{1})(?=\\d{3})|(\\d{6})(?=\\d{2}[A-Z])"
          replacement: "**********"

  loggers:
    root:
      level: info
      appenderRef:
        ref: STDOUT

El resultado es el siguiente:

1
2
3
...
2019-02-10 11:22:47,652  INFO                     io.github.picodotdev.blogbitix.log4j.Main Tarjeta de crédito: ****************111, DNI: ******11A
2019-02-10 11:22:47,653  INFO                     io.github.picodotdev.blogbitix.log4j.Main Tarjeta de crédito: **********111, DNI: **********11A

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando el comando ./gradlew run.