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.nio.file.Path;
|
import java.util.IdentityHashMap;
|
import java.util.Iterator;
|
import java.util.Map;
|
|
/** Ensures every normal management JSON response uses the same privacy rules. */
|
@ControllerAdvice
|
public class SensitiveDataMaskingAdvice implements ResponseBodyAdvice<Object> {
|
private final HttpServletRequest request;
|
public SensitiveDataMaskingAdvice(HttpServletRequest request) { this.request = request; }
|
|
@Override public boolean supports(MethodParameter type, Class<? extends HttpMessageConverter<?>> converter) {
|
// Only the explicit, admin-only and audited endpoint returns the source value.
|
return !request.getRequestURI().startsWith("/system/sensitive/");
|
}
|
@Override public Object beforeBodyWrite(Object body, MethodParameter type, MediaType mediaType,
|
Class<? extends HttpMessageConverter<?>> converter, org.springframework.http.server.ServerHttpRequest req,
|
org.springframework.http.server.ServerHttpResponse response) {
|
mask(body, new IdentityHashMap<Object, Boolean>()); return body;
|
}
|
private void mask(Object value, IdentityHashMap<Object, Boolean> seen) {
|
if (value == null || value instanceof String || value.getClass().isEnum() || seen.put(value, Boolean.TRUE) != null) return;
|
// Reactive streams and their internal queues are runtime infrastructure, not
|
// response DTOs. In particular, Reactor's MpscLinkedQueue implements
|
// Iterable but deliberately throws UnsupportedOperationException from
|
// iterator(). Do not introspect it (or any other reactive infrastructure).
|
if (isReactiveInfrastructure(value.getClass())) return;
|
// UnixPath implements Iterable. Its iterator creates a new Path for every
|
// segment, so identity-based cycle detection cannot stop the recursion.
|
if (value instanceof Path) return;
|
if (value instanceof Map) {
|
for (Object item : ((Map<?, ?>) value).entrySet()) {
|
Map.Entry entry = (Map.Entry) item; Object child = entry.getValue(); int kind = kind(String.valueOf(entry.getKey()));
|
if (child instanceof String && kind != 0) entry.setValue(maskText((String) child, kind)); else mask(child, seen);
|
}
|
return;
|
}
|
if (value instanceof Iterable) {
|
maskIterable((Iterable<?>) value, seen);
|
return;
|
}
|
if (value.getClass().isArray()) { for (int i = 0; i < Array.getLength(value); i++) mask(Array.get(value, i), seen); return; }
|
if (isJdkType(value.getClass())) 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 child = field.get(value); int kind = kind(field.getName());
|
if (child instanceof String && kind != 0) field.set(value, maskText((String) child, kind)); else mask(child, seen);
|
} catch (IllegalAccessException | RuntimeException ignored) { }
|
}
|
}
|
|
/**
|
* Some third-party containers expose Iterable only for API compatibility and
|
* intentionally do not support iteration. A response privacy filter must never
|
* turn such an implementation detail into a 500 response.
|
*/
|
private void maskIterable(Iterable<?> values, IdentityHashMap<Object, Boolean> seen) {
|
try {
|
Iterator<?> iterator = values.iterator();
|
while (iterator.hasNext()) mask(iterator.next(), seen);
|
} catch (UnsupportedOperationException ignored) {
|
// There is no safely accessible element to mask.
|
}
|
}
|
|
private boolean isReactiveInfrastructure(Class<?> type) {
|
String name = type.getName();
|
return name.startsWith("reactor.")
|
|| name.startsWith("org.reactivestreams.")
|
|| name.startsWith("io.netty.");
|
}
|
private boolean isJdkType(Class<?> type) {
|
String name = type.getName();
|
return name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("sun.");
|
}
|
private int kind(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 text, int kind) {
|
if (text == null) return null;
|
return kind == 1 ? (text.length() <= 7 ? stars(text.length()) : text.substring(0, 3) + "****" + text.substring(7))
|
: (text.length() <= 10 ? stars(text.length()) : text.substring(0, 6) + stars(text.length() - 10) + text.substring(text.length() - 4));
|
}
|
private String stars(int count) { StringBuilder result = new StringBuilder(); for (int i = 0; i < count; i++) result.append('*'); return result.toString(); }
|
}
|