Skip to content

Commit c0f4cba

Browse files
committed
GH-1140 Add data masking capabilities for JSON logging
Resolves #1140
1 parent 59fe298 commit c0f4cba

File tree

5 files changed

+454
-1
lines changed

5 files changed

+454
-1
lines changed

docs/modules/ROOT/pages/spring-cloud-function/programming-model.adoc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,28 @@ Spring Cloud Function will scan for implementations of `Function`, `Consumer` an
715715
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
716716
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.
717717

718+
719+
== Data Masking
720+
721+
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.
722+
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.
723+
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.
724+
725+
Here is the example of the contents of such file:
726+
727+
----
728+
eventSourceARN
729+
asdf1, SS
730+
----
731+
732+
Here you see three keys are defined
733+
Once such file exists, the JsonMasker will use it to mask values of the keys specified.
734+
735+
And here is the sample code that shows the usage
736+
737+
----
738+
private final static JsonMasker masker = JsonMasker.INSTANCE();
739+
. . .
740+
741+
logger.info("Received: " + masker.mask(new String(payload, StandardCharsets.UTF_8)));
742+
----

spring-cloud-function-adapters/spring-cloud-function-adapter-aws/src/main/java/org/springframework/cloud/function/adapter/aws/AWSLambdaUtils.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import org.springframework.cloud.function.context.catalog.FunctionTypeUtils;
3636
import org.springframework.cloud.function.json.JsonMapper;
37+
import org.springframework.cloud.function.utils.JsonMasker;
3738
import org.springframework.http.HttpStatus;
3839
import org.springframework.messaging.Message;
3940
import org.springframework.messaging.MessageHeaders;
@@ -67,6 +68,8 @@ public final class AWSLambdaUtils {
6768
*/
6869
public static final String AWS_CONTEXT = "aws-context";
6970

71+
private final static JsonMasker masker = JsonMasker.INSTANCE();
72+
7073
private AWSLambdaUtils() {
7174

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

108+
private static String mask(String value) {
109+
return masker.mask(value);
110+
}
111+
105112
@SuppressWarnings({ "unchecked", "rawtypes" })
106113
public static Message<byte[]> generateMessage(byte[] payload, Type inputType, boolean isSupplier,
107114
JsonMapper jsonMapper, Context context) {
108115
if (logger.isInfoEnabled()) {
109-
logger.info("Received: " + new String(payload, StandardCharsets.UTF_8));
116+
logger.info("Received: " + mask(new String(payload, StandardCharsets.UTF_8)));
110117
}
111118

112119
Object structMessage = jsonMapper.fromJson(payload, Object.class);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright 2024-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.function.utils;
18+
19+
import java.net.URI;
20+
import java.net.URL;
21+
import java.nio.charset.StandardCharsets;
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.Collection;
25+
import java.util.Enumeration;
26+
import java.util.List;
27+
import java.util.Map;
28+
import java.util.Set;
29+
import java.util.TreeSet;
30+
31+
import com.fasterxml.jackson.databind.ObjectMapper;
32+
import com.fasterxml.jackson.databind.SerializationFeature;
33+
import org.apache.commons.logging.Log;
34+
import org.apache.commons.logging.LogFactory;
35+
36+
import org.springframework.cloud.function.json.JacksonMapper;
37+
import org.springframework.cloud.function.json.JsonMapper;
38+
import org.springframework.util.ClassUtils;
39+
40+
41+
/**
42+
* @author Oleg Zhurakousky
43+
*/
44+
public final class JsonMasker {
45+
46+
private static final Log logger = LogFactory.getLog(JsonMasker.class);
47+
48+
private static JsonMasker jsonMasker;
49+
50+
private final JacksonMapper mapper;
51+
52+
private final Set<String> keysToMask;
53+
54+
private JsonMasker() {
55+
this.keysToMask = loadKeys();
56+
this.mapper = new JacksonMapper(new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT));
57+
58+
}
59+
60+
public synchronized static JsonMasker INSTANCE() {
61+
if (jsonMasker == null) {
62+
jsonMasker = new JsonMasker();
63+
}
64+
return jsonMasker;
65+
}
66+
67+
public synchronized static JsonMasker INSTANCE(Set<String> keysToMask) {
68+
INSTANCE().addKeys(keysToMask);
69+
return jsonMasker;
70+
}
71+
72+
public String[] getKeysToMask() {
73+
return keysToMask.toArray(new String[0]);
74+
}
75+
76+
public String mask(Object json) {
77+
if (!JsonMapper.isJsonString(json)) {
78+
return (String) json;
79+
}
80+
Object map = this.mapper.fromJson(json, Object.class);
81+
return this.iterate(map);
82+
}
83+
84+
@SuppressWarnings({ "unchecked" })
85+
private String iterate(Object json) {
86+
if (json instanceof Collection arrayValue) {
87+
for (Object element : arrayValue) {
88+
if (element instanceof Map mapElement) {
89+
for (Map.Entry<String, Object> entry : ((Map<String, Object>) mapElement).entrySet()) {
90+
this.doMask(entry.getKey(), entry);
91+
}
92+
}
93+
}
94+
}
95+
else if (json instanceof Map mapElement) {
96+
for (Map.Entry<String, Object> entry : ((Map<String, Object>) mapElement).entrySet()) {
97+
this.doMask(entry.getKey(), entry);
98+
}
99+
}
100+
return new String(this.mapper.toJson(json), StandardCharsets.UTF_8);
101+
}
102+
103+
private void doMask(String key, Map.Entry<String, Object> entry) {
104+
if (this.keysToMask.contains(key)) {
105+
entry.setValue("*******");
106+
}
107+
else if (entry.getValue() instanceof Map) {
108+
this.iterate(entry.getValue());
109+
}
110+
else if (entry.getValue() instanceof Collection) {
111+
this.iterate(entry.getValue());
112+
}
113+
}
114+
115+
private static Set<String> loadKeys() {
116+
Set<String> finalKeysToMask = new TreeSet<>();
117+
try {
118+
Enumeration<URL> resources = ClassUtils.getDefaultClassLoader().getResources("META-INF/mask.keys");
119+
while (resources.hasMoreElements()) {
120+
URI uri = resources.nextElement().toURI();
121+
List<String> lines = Files.readAllLines(Path.of(uri));
122+
for (String line : lines) {
123+
// need to split in case if delimited
124+
String[] keys = line.split(",");
125+
for (int i = 0; i < keys.length; i++) {
126+
finalKeysToMask.add(keys[i].trim());
127+
}
128+
}
129+
}
130+
}
131+
catch (Exception e) {
132+
logger.warn("Failed to load keys to mask. No keys will be masked", e);
133+
}
134+
return finalKeysToMask;
135+
}
136+
137+
private void addKeys(Set<String> keys) {
138+
this.keysToMask.addAll(keys);
139+
}
140+
}

0 commit comments

Comments
 (0)