刘帅
2026-09-22 ee9f387a56ca3fd620c56a91c1860201a98f0ca0
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.dsh.privacy;
 
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
 
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.IdentityHashMap;
import java.util.Map;
 
/**
 * Response boundary protection for personal phone and identity-card data.
 * It is deliberately based on field names so it also covers ad-hoc Maps
 * produced by reporting APIs.
 */
@ControllerAdvice
public class SensitiveDataMaskingAdvice implements ResponseBodyAdvice<Object> {
    private static final String INTERNAL_SENSITIVE_ACCESS = "X-Internal-Sensitive-Access";
 
    private final HttpServletRequest request;
 
    public SensitiveDataMaskingAdvice(HttpServletRequest request) {
        this.request = request;
    }
 
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // The only unmasked account response is the management-only endpoint.
        return !(request.getRequestURI().startsWith("/internal/sensitive/")
                && "true".equalsIgnoreCase(request.getHeader(INTERNAL_SENSITIVE_ACCESS)));
    }
 
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  org.springframework.http.server.ServerHttpRequest request,
                                  org.springframework.http.server.ServerHttpResponse response) {
        mask(body, new IdentityHashMap<Object, Boolean>());
        return body;
    }
 
    private void mask(Object value, IdentityHashMap<Object, Boolean> visited) {
        if (value == null || value instanceof String || value.getClass().isEnum() || visited.put(value, Boolean.TRUE) != null) return;
        // Path 的实现类(如 sun.nio.fs.UnixPath)本身是 Iterable;遍历其子路径会不断创建新对象,
        // IdentityHashMap 无法识别该循环,因而会导致 StackOverflowError。
        if (value instanceof java.nio.file.Path || isJdkType(value.getClass())) return;
        if (value instanceof Map) {
            for (Object entryObject : ((Map<?, ?>) value).entrySet()) {
                Map.Entry entry = (Map.Entry) entryObject;
                Object entryValue = entry.getValue();
                if (entryValue instanceof String && sensitiveType(String.valueOf(entry.getKey())) != 0) {
                    entry.setValue(maskText((String) entryValue, sensitiveType(String.valueOf(entry.getKey()))));
                } else mask(entryValue, visited);
            }
            return;
        }
        if (value instanceof Iterable) { for (Object item : (Iterable<?>) value) mask(item, visited); return; }
        if (value.getClass().isArray()) { for (int i = 0; i < Array.getLength(value); i++) mask(Array.get(value, i), visited); return; }
        for (Class<?> type = value.getClass(); type != null && type != Object.class; type = type.getSuperclass()) {
            for (Field field : type.getDeclaredFields()) {
                if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue;
                try {
                    field.setAccessible(true);
                    Object fieldValue = field.get(value);
                    int sensitive = sensitiveType(field.getName());
                    if (fieldValue instanceof String && sensitive != 0 && !field.isAnnotationPresent(UnmaskedSensitiveData.class)) {
                        field.set(value, maskText((String) fieldValue, sensitive));
                    }
                    else mask(fieldValue, visited);
                } catch (IllegalAccessException ignored) { }
            }
        }
    }
 
    private boolean isJdkType(Class<?> type) {
        String name = type.getName();
        return name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.");
    }
 
    private int sensitiveType(String name) {
        String key = name == null ? "" : name.replace("_", "").replace("-", "").toLowerCase();
        if (key.equals("idcard") || key.equals("identitycard") || key.equals("identitynumber") || key.equals("idnumber")) return 2;
        return key.equals("phone") || key.equals("mobile") || key.equals("telephone") || key.equals("tel") || key.endsWith("phone") || key.endsWith("mobile") ? 1 : 0;
    }
 
    private String maskText(String value, int sensitive) {
        if (value == null) return null;
        if (sensitive == 1) return value.length() <= 7 ? stars(value.length()) : value.substring(0, 3) + "****" + value.substring(7);
        return value.length() <= 10 ? stars(value.length()) : value.substring(0, 6) + stars(value.length() - 10) + value.substring(value.length() - 4);
    }
 
    private String stars(int count) {
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < count; i++) result.append('*');
        return result.toString();
    }
}