Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
"co.kr.pinhouse.domain.user.domain.repository",
"co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository",
"co.kr.pinhouse.domain.diagnostic.school.domain.repository",
"co.kr.pinhouse.domain.like.domain"
"co.kr.pinhouse.domain.like.domain",
"co.kr.pinhouse.domain.admin.audit.domain.repository",
"co.kr.pinhouse.domain.admin.notice.domain.repository",
"co.kr.pinhouse.domain.cs.domain.repository",
"co.kr.pinhouse.domain.ad.domain.repository"
})
@EnableMongoRepositories(basePackages = {
"co.kr.pinhouse.domain.housing.complex.domain.repository",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package co.kr.pinhouse.app.logging;

import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
import co.kr.pinhouse.common.util.LogSanitizer;

public class SanitizedMessageConverter extends MessageConverter {

@Override
public String convert(ILoggingEvent event) {
return LogSanitizer.sanitizeMessage(event.getFormattedMessage());
}
}
5 changes: 5 additions & 0 deletions module-app/src/main/resources/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ JWT_SAME_SITE=
CORS_FRONT_LOCAL=
CORS_FRONT_DEV=
CORS_FRONT_PROD=
CORS_ADMIN_FRONT_LOCAL=
CORS_ADMIN_FRONT_DEV=
CORS_ADMIN_FRONT_PROD=
CORS_FRONT_REDIRECT=
CORS_FRONT_REDIRECT_PATH=/api/auth/callback
CORS_ADMIN_FRONT_REDIRECT_PATH=/admin/auth/callback
CORS_BACK_DEV=

# KAKAO API Keys
Expand Down
5 changes: 5 additions & 0 deletions module-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ cors:
local: ${CORS_FRONT_LOCAL}
dev: ${CORS_FRONT_DEV}
prod: ${CORS_FRONT_PROD}
admin-local: ${CORS_ADMIN_FRONT_LOCAL:}
admin-dev: ${CORS_ADMIN_FRONT_DEV:}
admin-prod: ${CORS_ADMIN_FRONT_PROD:}
redirect: ${CORS_FRONT_REDIRECT}
redirect-path: ${CORS_FRONT_REDIRECT_PATH:/api/auth/callback}
admin-redirect-path: ${CORS_ADMIN_FRONT_REDIRECT_PATH:/admin/auth/callback}
back:
dev: ${CORS_BACK_DEV}

Expand Down
4 changes: 3 additions & 1 deletion module-app/src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<conversionRule conversionWord="sanitizedMsg"
converterClass="co.kr.pinhouse.app.logging.SanitizedMessageConverter"/>

<!-- Console Appender - 로컬/컨테이너 모두 stdout만 사용 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- Loki/Grafana가 로그 레벨을 안정적으로 인식할 수 있도록 level 키를 앞쪽에 둔다 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} -
%msg%n
%sanitizedMsg%n
</pattern>
</encoder>
</appender>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package co.kr.pinhouse.common.exception.code;

import org.springframework.http.HttpStatus;

import co.kr.pinhouse.common.response.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AdErrorCode implements ErrorCode {

NOT_FOUND_ADVERTISEMENT(404_400, HttpStatus.NOT_FOUND, "해당 광고를 찾을 수 없습니다."),
BAD_REQUEST_AD_SCHEDULE(400_400, HttpStatus.BAD_REQUEST, "광고 노출 일정이 올바르지 않습니다.");

private final Integer code;
private final HttpStatus httpStatus;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package co.kr.pinhouse.common.exception.code;

import org.springframework.http.HttpStatus;

import co.kr.pinhouse.common.response.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CsErrorCode implements ErrorCode {

NOT_FOUND_INQUIRY(404_300, HttpStatus.NOT_FOUND, "해당 문의를 찾을 수 없습니다."),
FORBIDDEN_INQUIRY_ACCESS(403_300, HttpStatus.FORBIDDEN, "해당 문의에 접근할 권한이 없습니다.");

private final Integer code;
private final HttpStatus httpStatus;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package co.kr.pinhouse.common.util;

import java.lang.reflect.Array;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public final class LogSanitizer {

private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+");
private static final Pattern NAME_FIELD_PATTERN = Pattern.compile(
"((?:(?:name|username|nickname|user_name)|이름|닉네임)\\s*[:=]\\s*)(\"[^\"]*\"|'[^']*'|[^,\\]\\)}]+)",
Pattern.CASE_INSENSITIVE);
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"(?i)(?<![A-Z0-9._%+-])[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}(?![A-Z0-9._%+-])");
private static final Pattern IPV4_PATTERN = Pattern.compile("\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b");
private static final Pattern JWT_PATTERN = Pattern.compile(
"\\b[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b");
private static final Pattern LONG_SECRET_PATTERN = Pattern.compile("\\b[A-Za-z0-9_]{24,}\\b");

private LogSanitizer() {
}

public static String sanitizeMessage(String message) {
if (message == null) {
return null;
}

String sanitized = sanitizePlainText(message);
sanitized = NAME_FIELD_PATTERN.matcher(sanitized)
.replaceAll(result -> result.group(1) + sanitizeNamedFieldValue(result.group(2)));
return sanitized;
}

public static Object sanitize(Object value) {
if (value == null) {
return null;
}
if (value instanceof UUID uuid) {
return uuid.toString();
}
if (value instanceof CharSequence charSequence) {
return sanitizeMessage(charSequence.toString());
}
if (value instanceof Optional<?> optional) {
return optional.map(LogSanitizer::sanitize).orElse(null);
}
if (value instanceof Iterable<?> iterable) {
return sanitizeIterable(iterable);
}
if (value instanceof Map<?, ?> map) {
return sanitizeMap(map);
}
if (value.getClass().isArray()) {
return sanitizeArray(value);
}
return value;
}

public static String sanitizeName(String value) {
if (value == null) {
return null;
}

String sanitized = sanitizePlainText(value).trim();
if (sanitized.isBlank()) {
return sanitized;
}
return maskName(sanitized);
}

private static Iterable<?> sanitizeIterable(Iterable<?> values) {
var sanitizedValues = new ArrayList<>();
for (Object value : values) {
sanitizedValues.add(sanitize(value));
}
return sanitizedValues;
}

private static Map<Object, Object> sanitizeMap(Map<?, ?> values) {
Map<Object, Object> sanitizedValues = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : values.entrySet()) {
sanitizedValues.put(sanitize(entry.getKey()), sanitize(entry.getValue()));
}
return sanitizedValues;
}

private static Iterable<?> sanitizeArray(Object array) {
int length = Array.getLength(array);
var sanitizedValues = new ArrayList<>(length);
for (int index = 0; index < length; index++) {
sanitizedValues.add(sanitize(Array.get(array, index)));
}
return sanitizedValues;
}

private static String sanitizeUrl(String rawUrl) {
try {
URI uri = new URI(rawUrl);
StringBuilder builder = new StringBuilder();

if (uri.getScheme() != null) {
builder.append(uri.getScheme()).append("://");
}
if (uri.getHost() != null) {
builder.append(uri.getHost());
} else if (uri.getAuthority() != null) {
builder.append(uri.getAuthority());
}
if (uri.getPort() != -1) {
builder.append(':').append(uri.getPort());
}
if (uri.getRawPath() != null) {
builder.append(sanitizePath(uri.getRawPath()));
}
if (uri.getRawQuery() != null) {
builder.append('?').append(sanitizeQuery(uri.getRawQuery()));
}
if (uri.getRawFragment() != null) {
builder.append("#***");
}
return builder.toString();
} catch (URISyntaxException exception) {
return maskMiddle(normalizeWhitespace(rawUrl), 6, 4);
}
}

private static String sanitizePlainText(String value) {
String sanitized = normalizeWhitespace(value);
sanitized = URL_PATTERN.matcher(sanitized).replaceAll(result -> sanitizeUrl(result.group()));
sanitized = EMAIL_PATTERN.matcher(sanitized).replaceAll(result -> maskEmail(result.group()));
sanitized = IPV4_PATTERN.matcher(sanitized).replaceAll(result -> maskIpv4(result.group()));
sanitized = JWT_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4));
sanitized = LONG_SECRET_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4));
return sanitized;
}

private static String sanitizeNamedFieldValue(String value) {
String trimmedValue = value.trim();
if (trimmedValue.length() >= 2
&& ((trimmedValue.startsWith("\"") && trimmedValue.endsWith("\""))
|| (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")))) {
String quote = trimmedValue.substring(0, 1);
return quote + sanitizeName(trimmedValue.substring(1, trimmedValue.length() - 1)) + quote;
}
return sanitizeName(trimmedValue);
}

private static String sanitizePath(String path) {
return Arrays.stream(path.split("/", -1))
.map(LogSanitizer::sanitizePathSegment)
.collect(Collectors.joining("/"));
}

private static String sanitizePathSegment(String segment) {
if (segment == null || segment.isBlank()) {
return segment;
}

String sanitized = EMAIL_PATTERN.matcher(segment).replaceAll(result -> maskEmail(result.group()));
sanitized = JWT_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4));
sanitized = LONG_SECRET_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4));
return sanitized;
}

private static String sanitizeQuery(String query) {
return Arrays.stream(query.split("&", -1))
.map(LogSanitizer::sanitizeQueryParameter)
.collect(Collectors.joining("&"));
}

private static String sanitizeQueryParameter(String parameter) {
if (parameter.isBlank()) {
return parameter;
}

int separatorIndex = parameter.indexOf('=');
if (separatorIndex < 0) {
return parameter;
}
return parameter.substring(0, separatorIndex + 1) + "***";
}

private static String maskEmail(String email) {
int separatorIndex = email.indexOf('@');
if (separatorIndex < 0) {
return email;
}

String localPart = email.substring(0, separatorIndex);
String domainPart = email.substring(separatorIndex + 1);
if (localPart.length() <= 2) {
return localPart.charAt(0) + "***@" + domainPart;
}
return localPart.substring(0, 2) + "***@" + domainPart;
}

private static String maskIpv4(String ipAddress) {
String[] parts = ipAddress.split("\\.");
if (parts.length != 4) {
return ipAddress;
}
return parts[0] + "." + parts[1] + "." + parts[2] + ".***";
}

private static String maskMiddle(String value, int prefixLength, int suffixLength) {
if (value == null || value.length() <= prefixLength + suffixLength) {
return "***";
}
return value.substring(0, prefixLength) + "***" + value.substring(value.length() - suffixLength);
}

private static String maskName(String value) {
return Arrays.stream(value.split("\\s+", -1))
.map(LogSanitizer::maskNameToken)
.collect(Collectors.joining(" "));
}

private static String maskNameToken(String value) {
if (value == null || value.isBlank()) {
return value;
}
if (value.length() == 1) {
return "*";
}
if (value.length() == 2) {
return value.charAt(0) + "*";
}
return value.charAt(0) + "*" + value.charAt(value.length() - 1);
}

private static String normalizeWhitespace(String value) {
return value
.replace('\r', ' ')
.replace('\n', ' ')
.replace('\t', ' ');
}
}
1 change: 1 addition & 0 deletions module-domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies {

// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
compileOnly 'jakarta.servlet:jakarta.servlet-api'
}

bootJar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

Expand All @@ -27,7 +28,7 @@ public abstract class BaseTimeEntity {
@Column(updatable = false)
private String createdBy;

@LastModifiedDate
@LastModifiedBy
private String updatedBy;

}
Loading
Loading