Skip to content

Commit

Permalink
GH-1140 Add data masking capabilities for JSON logging
Browse files Browse the repository at this point in the history
Resolves #1140
  • Loading branch information
olegz committed Apr 30, 2024
1 parent 59fe298 commit c0f4cba
Show file tree
Hide file tree
Showing 5 changed files with 454 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,28 @@ Spring Cloud Function will scan for implementations of `Function`, `Consumer` an
feature you can write functions that have no dependencies on Spring - not even the `@Component` annotation is needed. If you want to use a different
package, you can set `spring.cloud.function.scan.packages`. You can also use `spring.cloud.function.scan.enabled=false` to switch off the scan completely.


== Data Masking

A typical application comes with several levels of logging. Certain cloud/serverless platforms may include sensitive data in the packets that are being logged for everyone to see.
While it is the responsibility of individual developer to inspect the data that is being logged, so logging comes from the framework itself, so since version 4.1 we have introduced `JsonMasker` to initially help with masking sensitive data in AWS Lambda payloads. However, the `JsonMasker` is generic and is available to any module. At the moment it will only work with structured data such as JSON. All you need is to specify the keys you want to mask and it will take care of the rest.
Keys should be specified in the file `META-INF/mask.keys`. The format of the file is very simple where you can delimit several keys by commas or new line or both.

Here is the example of the contents of such file:

----
eventSourceARN
asdf1, SS
----

Here you see three keys are defined
Once such file exists, the JsonMasker will use it to mask values of the keys specified.

And here is the sample code that shows the usage

----
private final static JsonMasker masker = JsonMasker.INSTANCE();
. . .
logger.info("Received: " + masker.mask(new String(payload, StandardCharsets.UTF_8)));
----
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

import org.springframework.cloud.function.context.catalog.FunctionTypeUtils;
import org.springframework.cloud.function.json.JsonMapper;
import org.springframework.cloud.function.utils.JsonMasker;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
Expand Down Expand Up @@ -67,6 +68,8 @@ public final class AWSLambdaUtils {
*/
public static final String AWS_CONTEXT = "aws-context";

private final static JsonMasker masker = JsonMasker.INSTANCE();

private AWSLambdaUtils() {

}
Expand Down Expand Up @@ -102,11 +105,15 @@ public static Message<byte[]> generateMessage(byte[] payload, Type inputType, bo
return generateMessage(payload, inputType, isSupplier, jsonMapper, null);
}

private static String mask(String value) {
return masker.mask(value);
}

@SuppressWarnings({ "unchecked", "rawtypes" })
public static Message<byte[]> generateMessage(byte[] payload, Type inputType, boolean isSupplier,
JsonMapper jsonMapper, Context context) {
if (logger.isInfoEnabled()) {
logger.info("Received: " + new String(payload, StandardCharsets.UTF_8));
logger.info("Received: " + mask(new String(payload, StandardCharsets.UTF_8)));
}

Object structMessage = jsonMapper.fromJson(payload, Object.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Copyright 2024-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.cloud.function.utils;

import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.cloud.function.json.JacksonMapper;
import org.springframework.cloud.function.json.JsonMapper;
import org.springframework.util.ClassUtils;


/**
* @author Oleg Zhurakousky
*/
public final class JsonMasker {

private static final Log logger = LogFactory.getLog(JsonMasker.class);

private static JsonMasker jsonMasker;

private final JacksonMapper mapper;

private final Set<String> keysToMask;

private JsonMasker() {
this.keysToMask = loadKeys();
this.mapper = new JacksonMapper(new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT));

}

public synchronized static JsonMasker INSTANCE() {
if (jsonMasker == null) {
jsonMasker = new JsonMasker();
}
return jsonMasker;
}

public synchronized static JsonMasker INSTANCE(Set<String> keysToMask) {
INSTANCE().addKeys(keysToMask);
return jsonMasker;
}

public String[] getKeysToMask() {
return keysToMask.toArray(new String[0]);
}

public String mask(Object json) {
if (!JsonMapper.isJsonString(json)) {
return (String) json;
}
Object map = this.mapper.fromJson(json, Object.class);
return this.iterate(map);
}

@SuppressWarnings({ "unchecked" })
private String iterate(Object json) {
if (json instanceof Collection arrayValue) {
for (Object element : arrayValue) {
if (element instanceof Map mapElement) {
for (Map.Entry<String, Object> entry : ((Map<String, Object>) mapElement).entrySet()) {
this.doMask(entry.getKey(), entry);
}
}
}
}
else if (json instanceof Map mapElement) {
for (Map.Entry<String, Object> entry : ((Map<String, Object>) mapElement).entrySet()) {
this.doMask(entry.getKey(), entry);
}
}
return new String(this.mapper.toJson(json), StandardCharsets.UTF_8);
}

private void doMask(String key, Map.Entry<String, Object> entry) {
if (this.keysToMask.contains(key)) {
entry.setValue("*******");
}
else if (entry.getValue() instanceof Map) {
this.iterate(entry.getValue());
}
else if (entry.getValue() instanceof Collection) {
this.iterate(entry.getValue());
}
}

private static Set<String> loadKeys() {
Set<String> finalKeysToMask = new TreeSet<>();
try {
Enumeration<URL> resources = ClassUtils.getDefaultClassLoader().getResources("META-INF/mask.keys");
while (resources.hasMoreElements()) {
URI uri = resources.nextElement().toURI();
List<String> lines = Files.readAllLines(Path.of(uri));
for (String line : lines) {
// need to split in case if delimited
String[] keys = line.split(",");
for (int i = 0; i < keys.length; i++) {
finalKeysToMask.add(keys[i].trim());
}
}
}
}
catch (Exception e) {
logger.warn("Failed to load keys to mask. No keys will be masked", e);
}
return finalKeysToMask;
}

private void addKeys(Set<String> keys) {
this.keysToMask.addAll(keys);
}
}
Loading

0 comments on commit c0f4cba

Please sign in to comment.