diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java new file mode 100644 index 0000000..15e5031 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java @@ -0,0 +1,294 @@ +package com.eactive.apim.portal.apps.apis.filter; + +import com.eactive.apim.portal.common.util.HttpRequestUtil; +import com.eactive.apim.portal.common.util.StringMaskingUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.UUID; +import javax.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * API 테스트베드(/api/call-api) 감사(audit) 로그 기록기. + * + *

logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다. + * 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.

+ * + *

마스킹 정책:

+ * + */ +public final class ApiTesterAuditLogger { + + private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** 값 전체를 마스킹할 키(소문자 비교) */ + private static final Set SECRET_KEYS = new HashSet<>(Arrays.asList( + "client_secret", "clientsecret", "secret", "password", "passwd", "pwd", + "api_key", "apikey", "access_token", "refresh_token", "authorization")); + + /** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */ + private static final String[] AUDIT_HEADERS = { + "content-type", "accept", "referer", "origin", "x-forwarded-for", + "original-api-id", "authorization"}; + + /** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */ + private static final int JSON_LOG_MAX_LENGTH = 2000; + + /** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */ + private static final int NON_JSON_PREVIEW_LENGTH = 8; + + private ApiTesterAuditLogger() { + } + + /** REQ/RES 두 줄을 연결하는 짧은 감사 ID */ + public static String newAuditId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + /** + * 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다. + * + * @param targetUrl original-url 헤더 값 (없으면 null) + * @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-") + * @param tokenRequest OAuth 토큰 발급 요청 여부 + * @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열) + */ + public static void logRequest(String auditId, HttpServletRequest request, String targetUrl, + String gatewayMode, boolean tokenRequest, String body) { + try { + StringBuilder sb = new StringBuilder(256); + sb.append("REQ [").append(auditId).append(']'); + sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request)); + sb.append(" proxied=").append(HttpRequestUtil.isProxied(request)); + sb.append(" user=").append(currentUser()); + sb.append(" method=").append(request.getMethod()); + sb.append(" mode=").append(gatewayMode); + sb.append(" token=").append(tokenRequest); + sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl))); + sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"'); + sb.append(" headers=").append(buildHeaderSummary(request)); + sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body)); + auditLogger.info(sb.toString()); + } catch (Exception e) { + auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString()); + } + } + + /** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */ + public static void logResult(String auditId, int status, String type, long elapsedMillis) { + auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis); + } + + // ========================================================================= + // 요청 정보 구성 + // ========================================================================= + + /** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */ + private static String currentUser() { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) { + return "anonymous"; + } + String name = auth.getName(); + return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name); + } catch (Exception e) { + return "unknown"; + } + } + + /** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */ + private static String buildHeaderSummary(HttpServletRequest request) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (String name : AUDIT_HEADERS) { + String value = request.getHeader(name); + if (value == null) { + continue; + } + if (!first) { + sb.append(", "); + } + first = false; + sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"'); + } + return sb.append('}').toString(); + } + + /** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */ + private static String maskHeaderValue(String name, String value) { + if (!SECRET_KEYS.contains(name.toLowerCase())) { + return value; + } + int space = value.indexOf(' '); + if (space > 0) { + return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim()); + } + return partialMask(value); + } + + // ========================================================================= + // 본문 마스킹 + // ========================================================================= + + private static String buildBodySummary(String contentType, boolean tokenRequest, String body) { + if (body == null || body.isEmpty()) { + return "-"; + } + // 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹) + if (tokenRequest) { + return "\"" + maskFormBody(body) + "\""; + } + // 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만 + if (contentType != null && contentType.toLowerCase().contains("json")) { + String maskedJson = tryMaskJson(body); + if (maskedJson != null) { + return maskedJson; + } + } + return nonJsonSummary(body); + } + + /** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */ + private static String maskFormBody(String body) { + StringBuilder sb = new StringBuilder(body.length()); + String[] pairs = body.split("&"); + for (int i = 0; i < pairs.length; i++) { + if (i > 0) { + sb.append('&'); + } + int eq = pairs[i].indexOf('='); + if (eq < 0) { + sb.append(partialMask(pairs[i])); + continue; + } + String key = pairs[i].substring(0, eq); + String value = pairs[i].substring(eq + 1); + sb.append(key).append('='); + sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value)); + } + return sanitize(sb.toString()); + } + + /** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */ + private static String maskQueryValues(String url) { + int qs = url.indexOf('?'); + if (qs < 0) { + return url; + } + return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1)); + } + + /** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */ + private static String tryMaskJson(String body) { + try { + JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body)); + String out = OBJECT_MAPPER.writeValueAsString(masked); + if (out.length() > JSON_LOG_MAX_LENGTH) { + out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)"; + } + return out; + } catch (Exception e) { + return null; + } + } + + /** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */ + private static JsonNode maskJsonNode(JsonNode node) { + if (node.isObject()) { + ObjectNode obj = (ObjectNode) node; + Iterator names = obj.fieldNames(); + Set fieldNames = new HashSet<>(); + while (names.hasNext()) { + fieldNames.add(names.next()); + } + for (String field : fieldNames) { + if (SECRET_KEYS.contains(field.toLowerCase())) { + obj.set(field, TextNode.valueOf("*****")); + } else { + obj.set(field, maskJsonNode(obj.get(field))); + } + } + return obj; + } + if (node.isArray()) { + ArrayNode arr = (ArrayNode) node; + for (int i = 0; i < arr.size(); i++) { + arr.set(i, maskJsonNode(arr.get(i))); + } + return arr; + } + if (node.isNull() || node.isMissingNode()) { + return node; + } + return TextNode.valueOf(partialMask(node.asText())); + } + + /** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */ + private static String nonJsonSummary(String body) { + int bytes = body.getBytes(StandardCharsets.UTF_8).length; + String preview = body.length() <= NON_JSON_PREVIEW_LENGTH + ? body : body.substring(0, NON_JSON_PREVIEW_LENGTH); + return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")"; + } + + // ========================================================================= + // 공통 helper + // ========================================================================= + + /** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */ + private static String partialMask(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + int len = value.length(); + if (len <= 2) { + return stars(len); + } + int visible = Math.min(4, Math.max(1, len / 3)); + return value.substring(0, visible) + "***"; + } + + private static String stars(int count) { + char[] arr = new char[count]; + Arrays.fill(arr, '*'); + return new String(arr); + } + + /** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */ + private static String sanitize(String value) { + if (value == null) { + return "-"; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + sb.append('\''); + } else if (c == '\r' || c == '\n' || c == '\t') { + sb.append(' '); + } else if (c < 0x20) { + sb.append('?'); + } else { + sb.append(c); + } + } + return sb.toString(); + } +}