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 { private final HttpServletRequest request; public SensitiveDataMaskingAdvice(HttpServletRequest request) { this.request = request; } @Override public boolean supports(MethodParameter type, Class> 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> converter, org.springframework.http.server.ServerHttpRequest req, org.springframework.http.server.ServerHttpResponse response) { mask(body, new IdentityHashMap()); return body; } private void mask(Object value, IdentityHashMap 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 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(); } }