Mask sensitive data in logs with logback
Asked Answered
G

6

24

I need to be able to search an event for any one of a number of patterns and replace the text in the pattern with a masked value. This is a feature in our application intended to prevent sensitive information falling into the logs. As the information can be from a large variety of sources, it is not practical to apply filters on all the inputs. Besides there are uses for toString() beyond logging and I don't want toString() to uniformly mask for all calls (only logging).

I have tried using the %replace method in logback.xml:

<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %replace(%msg){'f k\="pin">(.*?)&lt;/f','f k\="pin">**********&lt;/f'}%n</pattern>

This was successful (after replacing the angle brackets with character entities), but it can only replace a single pattern. I would also like to perform the equivalent of

<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %replace(%msg){'pin=(.*?),','pin=**********,'}%n</pattern>

at the same time, but cannot. There is no way to mask two patterns in the one %replace.

The other way that has been loosely discussed on the interblags is extending something on the appender/encoder/layout hierarchy, but every attempt to intercept the ILoggingEvent has resulted in a collapse of the whole system, usually through instantiation errors or UnsupportedOperationException.

For example, I tried extending PatternLayout:

@Component("maskingPatternLayout")
public class MaskingPatternLayout extends PatternLayout {

    @Autowired
    private Environment env;

    @Override
    public String doLayout(ILoggingEvent event) {
        String message=super.doLayout(event);

        String patternsProperty = env.getProperty("bowdleriser.patterns");

        if( patternsProperty != null ) {
            String[] patterns = patternsProperty.split("|");
            for (int i = 0; i < patterns.length; i++ ) {
                Pattern pattern = Pattern.compile(patterns[i]);
                Matcher matcher = pattern.matcher(event.getMessage());
                matcher.replaceAll("*");
            }
        } else {
            System.out.println("Bowdleriser not cleaning! Naughty strings are getting through!");
        }

        return message;
    }
}

and then adjusting the logback.xml

<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <layout class="com.touchcorp.touchpoint.utils.MaskingPatternLayout">
      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </layout>
    </encoder>
  </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
      <file>logs/touchpoint.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>logs/touchpoint.%i.log.zip</fileNamePattern>
            <minIndex>1</minIndex>
            <maxIndex>3</maxIndex>
        </rollingPolicy>

        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <maxFileSize>10MB</maxFileSize>
        </triggeringPolicy>
      <encoder>
          <layout class="com.touchcorp.touchpoint.utils.MaskingPatternLayout">
            <pattern>%date{YYYY-MM-dd HH:mm:ss} %level [%thread] %logger{10} [%file:%line] %msg%n</pattern>
          </layout>
      </encoder>
    </appender>


  <logger name="com.touchcorp.touchpoint" level="DEBUG" />
  <logger name="org.springframework.web.servlet.mvc" level="TRACE" />

  <root level="INFO">
    <appender-ref ref="FILE" />
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

I have tried many other insertions, so I was wondering if anyone has actually achieved what I am attempting and if they could provide any clues or a solution.

Grous answered 13/8, 2014 at 4:13 Comment(0)
B
22

You need to wrap layout using LayoutWrappingEncoder. And also I believe you cannot use spring here as logback is not managed by spring.

Here is the updated class.

public class MaskingPatternLayout extends PatternLayout {

    private String patternsProperty;

    public String getPatternsProperty() {
        return patternsProperty;
    }

    public void setPatternsProperty(String patternsProperty) {
        this.patternsProperty = patternsProperty;
    }

    @Override
    public String doLayout(ILoggingEvent event) {
        String message = super.doLayout(event);
        
        if (patternsProperty != null) {
            String[] patterns = patternsProperty.split("\\|");
            for (int i = 0; i < patterns.length; i++) {
                Pattern pattern = Pattern.compile(patterns[i]);

                Matcher matcher = pattern.matcher(event.getMessage());
                if (matcher.find()) {
                    message = matcher.replaceAll("*");
                }
            }
        } else {

        }

        return message;
    }

}

And sample logback.xml

<appender name="fileAppender1" class="ch.qos.logback.core.FileAppender">
    <file>c:/logs/kp-ws.log</file>
    <append>true</append>
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
        <layout class="com.kp.MaskingPatternLayout">
            <patternsProperty>.*password.*|.*karthik.*</patternsProperty>
            <pattern>%d [%thread] %-5level %logger{35} - %msg%n</pattern>
        </layout>
    </encoder>
</appender>
<root level="DEBUG">
    <appender-ref ref="fileAppender1" />
</root>

UPDATE

Here its better approach, set Pattern during init itself. such that we can avoid recreating Pattern again and again and this implementation is close to realistic usecase.

public class MaskingPatternLayout extends PatternLayout {

    private String patternsProperty;
    private Optional<Pattern> pattern;

    public String getPatternsProperty() {
        return patternsProperty;
    }

    public void setPatternsProperty(String patternsProperty) {
        this.patternsProperty = patternsProperty;
        if (this.patternsProperty != null) {
            this.pattern = Optional.of(Pattern.compile(patternsProperty, Pattern.MULTILINE));
        } else {
            this.pattern = Optional.empty();
        }
    }

        @Override
        public String doLayout(ILoggingEvent event) {
            final StringBuilder message = new StringBuilder(super.doLayout(event));
    
            if (pattern.isPresent()) {
                Matcher matcher = pattern.get().matcher(message);
                while (matcher.find()) {
    
                    int group = 1;
                    while (group <= matcher.groupCount()) {
                        if (matcher.group(group) != null) {
                            for (int i = matcher.start(group); i < matcher.end(group); i++) {
                                message.setCharAt(i, '*');
                            }
                        }
                        group++;
                    }
                }
            }
            return message.toString();
        }
    
    }

And the updated Configuration file.

<appender name="fileAppender1" class="ch.qos.logback.core.FileAppender">
    <file>c:/logs/kp-ws.log</file>
    <append>true</append>
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
        <layout class="com.kp.MaskingPatternLayout">
            <patternsProperty>(password)|(karthik)</patternsProperty>
            <pattern>%d [%thread] %-5level %logger{35} - %msg%n</pattern>
        </layout>
    </encoder>
</appender>
<root level="DEBUG">
    <appender-ref ref="fileAppender1" />
</root>

Output

My username=test and password=*******
Barajas answered 13/8, 2014 at 7:5 Comment(11)
Thanks Karthik, this is a good solution, I only need to adjust it slightly to not replace whole strings just parts of the patterns.Grous
One way to integrate this with spring is to mark the class as a @Component. The class must have a default constructor and an @Autowired constructor. The autowired constructor can then set static properties on the class to fill in the required information. Or you can use org.springframework.core.io.support.PathMatchingResourcePatternResolver to inspect the claspath. This isn't using Spring directory, but allows you to look for things like annotated classes.Undershot
Also, as the code stands, it looks like only the last pattern match will be applied.Stockton
how can I go about this if I am using log4j, not logbackCotquean
<patternsProperty>(password)|(karthik)</patternsProperty> How to use it to mask few fields from my model class?Eurythmics
@KarthikPrasad by using your above code for me its masking "My username=test and password=******* " password word itself and printing value of password normally, how did it worked for you?Eurythmics
@Eurythmics This is a sample example, you should modify the pattern accordingly.Barajas
@KarthikPrasad I tried lot of several ways but unable to find. Do you have any hint or suggestion to do masking based on pojo class field names?Eurythmics
You can try <patternsProperty>password=(\w+)|(karthik)</patternsProperty>Barajas
@Eurythmics - your concern is fixed here - #50575594 --- this will mask the value of the field and not the field name itselfCalia
@KarthikPrasad, your solution mask the field name and not the field value. this issue is fixed at - #50575594Calia
D
8

From the documentation:

replace(p){r, t}    

The pattern p can be arbitrarily complex and in particular can contain multiple conversion keywords.

Facing same problem having to replace 2 patterns in a message, I just tried to chain so p is just an invocation of replace, in my case:

%replace(  %replace(%msg){'regex1', 'replacement1'}  ){'regex2', 'replacement2'}

Worked great, though I wonder if I'm pushing it a bit and p can be indeed that arbitrarily complex.

Dobbins answered 17/1, 2017 at 0:28 Comment(2)
I confirm the solution for multiple conversion keywords using chaining of %replace also worked for me, many thanks to Dmitri!!! @EladTabak This solution worked for me.Melisamelisande
Missed the double replace there. Works like charm. Thanks!Koblick
M
4

A very similar but slightly different approach evolves around customizing CompositeConverter and defining a <conversionRule ...> within the logback that references the custom converter.

In one of my tech-demo projects I defined a MaskingConverter class that defines a series of patterns the logging event is analyzed with and on a match updated which is used inside my logback configuration.

As link-only answers are not that beloved here at SO I'll post the important parts of the code here and explain what it does and why it is set up like that. Starting with the Java-based custom converter class:

public class MaskingConverter<E extends ILoggingEvent> extends CompositeConverter<E> {

  public static final String CONFIDENTIAL = "CONFIDENTIAL";
  public static final Marker CONFIDENTIAL_MARKER = MarkerFactory.getMarker(CONFIDENTIAL);

  private Pattern keyValPattern;
  private Pattern basicAuthPattern;
  private Pattern urlAuthorizationPattern;

  @Override
  public void start() {
    keyValPattern = Pattern.compile("(pw|pwd|password)=.*?(&|$)");
    basicAuthPattern = Pattern.compile("(B|b)asic ([a-zA-Z0-9+/=]{3})[a-zA-Z0-9+/=]*([a-zA-Z0-9+/=]{3})");
    urlAuthorizationPattern = Pattern.compile("//(.*?):.*?@");
    super.start();
  }

  @Override
  protected String transform(E event, String in) {
    if (!started) {
      return in;
    }
    Marker marker = event.getMarker();
    if (null != marker && CONFIDENTIAL.equals(marker.getName())) {
      // key=value[&...] matching
      Matcher keyValMatcher = keyValPattern.matcher(in);
      // Authorization: Basic dXNlcjpwYXNzd29yZA==
      Matcher basicAuthMatcher = basicAuthPattern.matcher(in);
      // sftp://user:password@host:port/path/to/resource
      Matcher urlAuthMatcher = urlAuthorizationPattern.matcher(in);

      if (keyValMatcher.find()) {
        String replacement = "$1=XXX$2";
        return keyValMatcher.replaceAll(replacement);
      } else if (basicAuthMatcher.find()) {
        return basicAuthMatcher.replaceAll("$1asic $2XXX$3");
      } else if (urlAuthMatcher.find()) {
        return urlAuthMatcher.replaceAll("//$1:XXX@");
      }
    }
    return in;
  }
}

This class defines a number of RegEx patterns the respective log-line should be compared against and on a match lead to an update of the event by masking the passwords.

Note that this code sample assumes that a log line only contains one kind of password. You are of course free to adapt the bahvior to your needs in case you want to probe each line for multiple pattern matches.

To apply this converter one simply has to add the following line to the logback configuration:

<conversionRule conversionWord="mask" converterClass="at.rovo.awsxray.utils.MaskingConverter"/>

which defines a new function mask which can be used in a pattern in order to mask any log events matching any of the patterns defined in the custom converter. This function can now be used inside a pattern to tell Logback to perform the logic on each log event. The respective pattern might be something along the lines below:

<property name="patternValue"
          value="%date{yyyy-MM-dd HH:mm:ss} [%-5level] - %X{FILE_ID} - %mask(%msg) [%thread] [%logger{5}] %n"/>

<!-- Appender definitions-->

<appender class="ch.qos.logback.core.ConsoleAppender" name="console">
    <encoder>
        <pattern>${patternValue}</pattern>
    </encoder>
</appender>

where %mask(%msg) will take the original log-line as input and perform the password masking on each of the lines passed to that function.

As probing each line for one or multiple pattern matches might be costly, the Java code above includes Markers that can be used in log statements to send certain meta information on the log statement itself to Logback/SLF4J. Based on such markers different behaviors might be achievable. In the scenario presented a marker interface can be used to tell Logback that the respective log line contains confidential information and thus requires masking if it matches. Any log line that isn't marked as confidential will be ignored by this converter which helps in pumping out the lines faster as no pattern matching needs to be performed on those lines.

In Java such a marker can be added to a log statement like this:

LOG.debug(MaskingConverter.CONFIDENTIAL_MARKER, "Received basic auth header: {}",
      connection.getBasicAuthentication());

which might produce a log line similar to Received basic auth header: Basic QlRXXXlQ= for the above mentioned custom converter, which leaves the first and last couple of characters in tact but obfuscates the middle bits with XXX.

Mccrea answered 17/1, 2019 at 15:45 Comment(0)
P
4

Here is my approach, maybe it can help somebody

Try this one. 1. First of all, we should create a class for handling our logs (each row)

public class PatternMaskingLayout extends PatternLayout {

private Pattern multilinePattern;
private List<String> maskPatterns = new ArrayList<>();

public void addMaskPattern(String maskPattern) { // invoked for every single entry in the xml
    maskPatterns.add(maskPattern);
    multilinePattern = Pattern.compile(
            String.join("|", maskPatterns), // build pattern using logical OR
            Pattern.MULTILINE
    );
}

@Override
public String doLayout(ILoggingEvent event) {
    return maskMessage(super.doLayout(event)); // calling superclass method is required
}

private String maskMessage(String message) {
    if (multilinePattern == null) {
        return message;
    }
    StringBuilder sb = new StringBuilder(message);
    Matcher matcher = multilinePattern.matcher(sb);
    while (matcher.find()) {
        if (matcher.group().contains("creditCard")) {
            maskCreditCard(sb, matcher);
        } else if (matcher.group().contains("email")) {
            // your logic for this case
        }
    }
    return sb.toString();
}
private void maskCreditCard(StringBuilder sb, Matcher matcher) {
    //here is our main logic for masking sensitive data
    String targetExpression = matcher.group();
    String[] split = targetExpression.split("=");
    String pan = split[1];
    String maskedPan = Utils.getMaskedPan(pan);
    int start = matcher.start() + split[0].length() + 1;
    int end = matcher.end();
    sb.replace(start, end, maskedPan);
}

}

  1. The second step is we should create appender for logback into logback.xml

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
        <layout class="com.bpcbt.micro.utils.PatternMaskingLayout">
            <maskPattern>creditCard=\d+</maskPattern> <!-- SourcePan pattern -->
            <pattern>%d{dd/MM/yyyy HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n%ex</pattern>-->
        </layout>
    </encoder>
    

  2. Now we can use logger into our code

    log.info("card context set for creditCard={}", creditCard);

  3. As a result, we will see

    one row from logs

    card context set for creditCard=11111******111

without these options, our logs would be like this row

card context set for creditCard=1111111111111
Pincus answered 22/4, 2020 at 8:49 Comment(0)
M
1

I've used censor based on RegexCensor from library https://github.com/tersesystems/terse-logback. In logback.xml

<!--censoring information-->
<newRule pattern="*/censor" actionClass="com.tersesystems.logback.censor.CensorAction"/>
<conversionRule conversionWord="censor" converterClass="com.tersesystems.logback.censor.CensorConverter" />
<!--impl inspired by com.tersesystems.logback.censor.RegexCensor -->
<censor name="censor-sensitive" class="com.mycompaqny.config.logging.SensitiveDataCensor"></censor>

where i put list regex replacements.

@Getter@Setter    
public class SensitiveDataCensor extends ContextAwareBase implements Censor, LifeCycle {
    protected volatile boolean started = false;
    protected String name;
    private List<Pair<Pattern, String>> replacementPhrases = new ArrayList<>();

    public void start() {

        String ssnJsonPattern = "\"(ssn|socialSecurityNumber)(\"\\W*:\\W*\".*?)-(.*?)\"";
        replacementPhrases.add(Pair.of(Pattern.compile(ssnJsonPattern), "\"$1$2-****\""));

        String ssnXmlPattern = "<(ssn|socialSecurityNumber)>(\\W*.*?)-(.*?)</";
        replacementPhrases.add(Pair.of(Pattern.compile(ssnXmlPattern), "<$1>$2-****</"));

        started = true;
    }

    public void stop() {
        replacementPhrases.clear();
        started = false;
    }

    public CharSequence censorText(CharSequence original) {
        CharSequence outcome = original;
        for (Pair<Pattern, String> replacementPhrase : replacementPhrases) {
            outcome = replacementPhrase.getLeft().matcher(outcome).replaceAll(replacementPhrase.getRight());
        } 
        return outcome;
    }
}

and used it in logback.xml like this

<message>[ignore]</message> <---- IMPORTANT to disable original message field so you get only censored message
...
<pattern>
    {"message": "%censor(%msg){censor-sensitive}"}
</pattern>

Marr answered 6/6, 2019 at 9:39 Comment(0)
M
1

I was trying to mask some sensitive data in my demo project logs. I tried with but it didn't worked for me because of Java Reflections as I took variable name as pattern. I am adding the solution which worked for me incase if it helps anyone else also.

I added below code in logback.xml(inside encoder tag) file for masking field1 and field2 information in the logs.

<encoder class="com.demo.config.CustomJsonMaskLogEncoder">
<patterns>
    <pattern>\"field1\"\s*:\s*\"(.*?)\"</pattern>
    <pattern>\"field2\"\s*:\s*\"(.*?)\"</pattern>
    <pattern>%-5p [%d{ISO8601,UTC}] [%thread] %c: %m%n%rootException</pattern>
</patterns>
</encoder>

I have written a CustomJsonMaskLogEncoder which does the job of masking the field data as per regex.

package com.demo.config;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.classic.spi.LoggingEvent;
import java.util.ArrayList;

import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder;
import org.slf4j.LoggerFactory;

public class CustomJsonMaskLogEncoder extends LoggingEventCompositeJsonEncoder {

    private final CustomPatternMaskingLayout customPatternMaskingLayout;
    private boolean maskEnabled;

    public JsonMaskLogEncoder() {
        super();
        customPatternMaskingLayout = new CustomPatternMaskingLayout();
        maskEnabled = true;
    }

    @Override
    public byte[] encode(ILoggingEvent event) {
        return maskEnabled ? getMaskedJson(event) : super.encode(event);
    }

    private byte[] getMaskedJson(ILoggingEvent event) {
        final Logger logger =
                (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(event.getLoggerName());
        final String message = customPatternMaskingLayout.maskMessage(event.getFormattedMessage());

        final LoggingEvent loggingEvent =
                new LoggingEvent(
                        "", logger, event.getLevel(), message, getThrowable(event), event.getArgumentArray());

        return super.encode(loggingEvent);
    }

    private Throwable getThrowable(ILoggingEvent event) {
        return event.getThrowableProxy() == null ? null : new Throwable(getStackTrace(event));
    }

    private String getStackTrace(ILoggingEvent event) {
        final ExtendedThrowableProxyConverter throwableConverter =
                new ExtendedThrowableProxyConverter();

        throwableConverter.start();

        final String errorMessageWithStackTrace = throwableConverter.convert(event);
        throwableConverter.stop();

        return errorMessageWithStackTrace;
    }

    @SuppressWarnings("unused")
    public void setEnableMasking(boolean enabled) {
        this.maskEnabled = enabled;
    }

    @SuppressWarnings("unused")
    public void setPatterns(Patterns patterns) {
        customPatternMaskingLayout.addMaskPatterns(patterns);
    }

    public static class Patterns extends ArrayList<String> {
        @SuppressWarnings("unused")
        public void addPattern(String pattern) {
            add(pattern);
        }
    }
}

And below is the code for actual CustomPatternMaskingLayout:

package com.demo.config;

import static java.lang.String.format;

import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;


public class CustomPatternMaskingLayout {

    private Pattern multilinePattern;
    private final List<String> maskPatterns = new ArrayList<>();

    public CustomPatternMaskingLayout() {
        compilePattern();
    }

    void addMaskPatterns(CustomJsonMaskLogEncoder.Patterns patterns) {
        maskPatterns.addAll(patterns);
        compilePattern();
    }

    private void compilePattern() {
        multilinePattern = Pattern.compile(String.join("|", maskPatterns),Pattern.MULTILINE);
    }

    String maskMessage(String message) {
        if (multilinePattern == null) {
            return message;
        }
        StringBuilder sb = new StringBuilder(message);
        Matcher matcher = multilinePattern.matcher(sb);
        while (matcher.find()) {
            IntStream.rangeClosed(1, matcher.groupCount()).forEach(group -> {
                if (matcher.group(group) != null) {
                    IntStream.range(matcher.start(group), matcher.end(group)).forEach(i -> sb.setCharAt(i, '*'));
                }
            });
        }
        return sb.toString();
    }
}

Hope this helps!!!

Maryannamaryanne answered 11/8, 2022 at 13:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.