마스킹 유틸리티 추가 (MaskingUtils + 단위테스트)
- 보안아키텍처 마스킹 정책에 따라 고유식별정보·금융식별정보·인증정보·신상정보·온라인정보 - 카테고리별 마스킹 처리 유틸 및 JUnit 5 단위테스트(3단계 번호 체계) 신규 작성
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
package com.eactive.eai.common.util;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 보안아키텍처 마스킹 정책 구현 유틸리티
|
||||
*
|
||||
* <pre>
|
||||
* [고유식별정보]
|
||||
* 주민등록번호 123456-1234567 → 123456-1******
|
||||
* 운전면허번호 제주-22-300001-50 → 제주-22-3*****-**
|
||||
* 여권번호 M12345678 → M1234****
|
||||
* 외국인등록번호 123456-5234567 → 123456-5******
|
||||
*
|
||||
* [금융식별정보]
|
||||
* 계좌번호 99-912345-1 → 99-9*-*****1
|
||||
* 카드번호 4518-4212-3456-1234 → 4518-42**-****-1234
|
||||
* 유효기간 12/25 → **/**
|
||||
* CVV 123 → ***
|
||||
*
|
||||
* [인증정보]
|
||||
* 비밀번호 password → ********
|
||||
*
|
||||
* [신상정보]
|
||||
* 전화번호 064-1234-5678 → 064-12**-56**
|
||||
* 이메일 aajjbacc@shinhan.com → **jjba**@shinhan.com
|
||||
* 한글 성명 홍길동 → 홍*동
|
||||
* 영문 성명 GILDONG HONG → ******* HONG
|
||||
* 지번주소 제주특별자치도 제주시 연동 123번지, 101동 202호 → 제주특별자치도 제주시 연동 ***번지, **** ****
|
||||
* 도로명주소 제주특별자치도 제주시 은남2길 5, 101동 202호 → 제주특별자치도 제주시 은남**길 ***, **** ****
|
||||
*
|
||||
* [온라인 정보]
|
||||
* IPv4 100.200.123.45 → 100.200.***.45
|
||||
* IPv6 21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A → 21DA:00D3:0000:2F3B:02AA:00FF:FE28:****
|
||||
* </pre>
|
||||
*/
|
||||
public final class MaskingUtils {
|
||||
|
||||
private static final char MASK = '*';
|
||||
|
||||
private MaskingUtils() {}
|
||||
|
||||
// =========================================================================
|
||||
// 고유식별정보
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 주민등록번호 마스킹: 뒷 6자리 마스킹
|
||||
* <pre>123456-1234567 → 123456-1******</pre>
|
||||
*/
|
||||
public static String maskResidentNumber(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
int idx = value.indexOf('-');
|
||||
if (idx < 0) {
|
||||
return maskTailChars(value, 6);
|
||||
}
|
||||
// 하이픈 + 성별 1자리까지 노출, 이후 6자리 마스킹
|
||||
String front = value.substring(0, Math.min(idx + 2, value.length()));
|
||||
return front + stars(6);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운전면허번호 마스킹: 뒤에서부터 숫자 7자리 마스킹
|
||||
* <pre>제주-22-300001-50 → 제주-22-3*****-**</pre>
|
||||
*/
|
||||
public static String maskDriverLicense(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
char[] chars = value.toCharArray();
|
||||
int digitCount = 0;
|
||||
for (int i = chars.length - 1; i >= 0 && digitCount < 7; i--) {
|
||||
if (Character.isDigit(chars[i])) {
|
||||
chars[i] = MASK;
|
||||
digitCount++;
|
||||
}
|
||||
}
|
||||
return new String(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여권번호 마스킹: 뒤에서부터 4자리 마스킹
|
||||
* <pre>M12345678 → M1234****</pre>
|
||||
*/
|
||||
public static String maskPassport(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
return maskTailChars(value, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외국인등록번호 마스킹: 뒷 6자리 마스킹 (주민등록번호와 동일)
|
||||
* <pre>123456-5234567 → 123456-5******</pre>
|
||||
*/
|
||||
public static String maskForeignerNumber(String value) {
|
||||
return maskResidentNumber(value);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 금융식별정보
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 계좌번호 마스킹: 첫 3자리, 마지막 1자리만 노출
|
||||
* <pre>99-912345-1 → 99-9*-*****1</pre>
|
||||
*/
|
||||
public static String maskAccountNumber(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
String digitsOnly = value.replaceAll("[^0-9]", "");
|
||||
int totalDigits = digitsOnly.length();
|
||||
if (totalDigits <= 4) return value;
|
||||
|
||||
char[] chars = value.toCharArray();
|
||||
int digitIdx = 0;
|
||||
for (int i = 0; i < chars.length; i++) {
|
||||
if (Character.isDigit(chars[i])) {
|
||||
digitIdx++;
|
||||
// 4번째 자리부터 마지막 자리 직전까지 마스킹
|
||||
if (digitIdx > 3 && digitIdx < totalDigits) {
|
||||
chars[i] = MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new String(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드번호 마스킹: PCI-DSS 기준 7~12번째 숫자 자리 마스킹
|
||||
* <pre>4518-4212-3456-1234 → 4518-42**-****-1234</pre>
|
||||
*/
|
||||
public static String maskCardNumber(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
char[] chars = value.toCharArray();
|
||||
int digitIdx = 0;
|
||||
for (int i = 0; i < chars.length; i++) {
|
||||
if (Character.isDigit(chars[i])) {
|
||||
digitIdx++;
|
||||
if (digitIdx >= 7 && digitIdx <= 12) {
|
||||
chars[i] = MASK;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new String(chars);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 유효기간 마스킹: 월/년 모두 마스킹
|
||||
* <pre>12/25 → **/**</pre>
|
||||
*/
|
||||
public static String maskCardExpiry(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
return value.replaceAll("[0-9]", String.valueOf(MASK));
|
||||
}
|
||||
|
||||
/**
|
||||
* CVV 마스킹: 3자리 전체 마스킹
|
||||
* <pre>123 → ***</pre>
|
||||
*/
|
||||
public static String maskCvv(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
return stars(value.length());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 인증정보
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 비밀번호 마스킹: 전체 자리 마스킹
|
||||
* <pre>password1234 → ************</pre>
|
||||
*/
|
||||
public static String maskPassword(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
return stars(value.length());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 신상정보
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 전화번호/FAX 마스킹: 가운데 뒷 2자리, 뒷자리 뒷 2자리 마스킹
|
||||
* 하이픈 없는 형식은 자릿수·접두사 기반으로 세그먼트를 분리한 뒤 마스킹하고 하이픈 없이 반환
|
||||
* <pre>
|
||||
* 064-1234-5678 → 064-12**-56**
|
||||
* 06412345678 → 0641234**56**
|
||||
* 01012345678 → 01012**56**
|
||||
* </pre>
|
||||
*/
|
||||
public static String maskPhoneNumber(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
boolean hasHyphen = value.contains("-");
|
||||
String normalized = hasHyphen ? value : normalizePhoneNumber(value);
|
||||
String[] parts = normalized.split("-");
|
||||
if (parts.length == 3) {
|
||||
String m0 = parts[0];
|
||||
String m1 = maskSegmentTail(parts[1], 2);
|
||||
String m2 = maskSegmentTail(parts[2], 2);
|
||||
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
|
||||
* <pre>
|
||||
* 02 + 7자리 → 02-XXX-XXXX
|
||||
* 02 + 8자리 → 02-XXXX-XXXX
|
||||
* 0XX + 7자리 → 0XX-XXX-XXXX
|
||||
* 0XX + 8자리 → 0XX-XXXX-XXXX
|
||||
* </pre>
|
||||
*/
|
||||
private static String normalizePhoneNumber(String digits) {
|
||||
int len = digits.length();
|
||||
if (digits.startsWith("02")) {
|
||||
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
|
||||
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
|
||||
} else if (digits.startsWith("0")) {
|
||||
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
|
||||
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 번호 마스킹: 가운데 뒷 2자리, 뒷자리 뒷 2자리 마스킹
|
||||
* <pre>010-1234-5678 → 010-12**-56**</pre>
|
||||
*/
|
||||
public static String maskMobileNumber(String value) {
|
||||
return maskPhoneNumber(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 마스킹: 앞 2자리와 @ 앞 2자리 마스킹
|
||||
* <pre>aajjbacc@shinhan.com → **jjba**@shinhan.com</pre>
|
||||
*/
|
||||
public static String maskEmail(String value) {
|
||||
if (value == null || value.isEmpty() || !value.contains("@")) return value;
|
||||
int atIdx = value.indexOf('@');
|
||||
String local = value.substring(0, atIdx);
|
||||
String domain = value.substring(atIdx);
|
||||
if (local.length() <= 4) {
|
||||
return stars(local.length()) + domain;
|
||||
}
|
||||
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/한자 성명 마스킹
|
||||
* <pre>
|
||||
* 2자: 뒤 1자리 마스킹 홍동 → 홍*
|
||||
* 3자: 가운데 1자리 마스킹 홍길동 → 홍*동
|
||||
* 4자 이상: 앞뒤 1자 제외한 가운데 전체 마스킹 홍길순동 → 홍**동
|
||||
* </pre>
|
||||
*/
|
||||
public static String maskKoreanName(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
int len = value.length();
|
||||
if (len == 1) return value;
|
||||
if (len == 2) {
|
||||
return String.valueOf(value.charAt(0)) + MASK;
|
||||
}
|
||||
if (len == 3) {
|
||||
return String.valueOf(value.charAt(0)) + MASK + value.charAt(2);
|
||||
}
|
||||
// 4자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
|
||||
return String.valueOf(value.charAt(0)) + stars(len - 2) + value.charAt(len - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 영문 성명 마스킹: 성(last name)만 노출, 이름(first name)은 마스킹
|
||||
* <pre>GILDONG HONG → ******* HONG</pre>
|
||||
* (마지막 공백 구분 토큰이 성(last name)으로 간주)
|
||||
*/
|
||||
public static String maskEnglishName(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
String[] parts = value.trim().split("\\s+");
|
||||
if (parts.length == 1) return value;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
if (i > 0) sb.append(' ');
|
||||
sb.append(stars(parts[i].length()));
|
||||
}
|
||||
sb.append(' ').append(parts[parts.length - 1]);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지번주소 마스킹: 번지 이하 주소 마스킹 (String.length() 보존)
|
||||
* <pre>
|
||||
* 제주특별자치도 제주시 연동 123번지, 101동 202호 → 제주특별자치도 제주시 연동 ***번지, **** ****
|
||||
* </pre>
|
||||
*/
|
||||
public static String maskJibunAddress(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
// 번지 앞 숫자 자릿수 보존 마스킹
|
||||
String result = maskDigitsBefore(value, "번지");
|
||||
// 쉼표 이후 상세주소 마스킹 (공백 유지, 자릿수 보존)
|
||||
int commaIdx = result.indexOf(',');
|
||||
if (commaIdx >= 0) {
|
||||
String after = result.substring(commaIdx + 1);
|
||||
result = result.substring(0, commaIdx + 1) + after.replaceAll("[^\\s]", String.valueOf(MASK));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도로명주소 마스킹: 도로명 번호 및 번지 이하 마스킹 (String.length() 보존)
|
||||
* <pre>
|
||||
* 제주특별자치도 제주시 은남2길 5, 101동 202호 → 제주특별자치도 제주시 은남*길 *, **** ****
|
||||
* </pre>
|
||||
*/
|
||||
public static String maskRoadAddress(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
// 도로명 번호 자릿수 보존 마스킹 (긴 suffix 먼저 처리)
|
||||
String result = maskDigitsBefore(value, "대로");
|
||||
result = maskDigitsBefore(result, "번길");
|
||||
result = maskDigitsBefore(result, "길");
|
||||
result = maskDigitsBefore(result, "로");
|
||||
// 도로명 뒤 집 번호 자릿수 보존 마스킹
|
||||
result = maskDigitsAfter(result, "대로");
|
||||
result = maskDigitsAfter(result, "번길");
|
||||
result = maskDigitsAfter(result, "길");
|
||||
result = maskDigitsAfter(result, "로");
|
||||
// 쉼표 이후 상세주소 마스킹 (공백 유지, 자릿수 보존)
|
||||
int commaIdx = result.indexOf(',');
|
||||
if (commaIdx >= 0) {
|
||||
String after = result.substring(commaIdx + 1);
|
||||
result = result.substring(0, commaIdx + 1) + after.replaceAll("[^\\s]", String.valueOf(MASK));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 온라인 정보
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* IPv4 마스킹: 3번째 옥텟(17~24 비트) 마스킹
|
||||
* <pre>100.200.123.45 → 100.200.***.45</pre>
|
||||
*/
|
||||
public static String maskIpv4(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
String[] parts = value.split("\\.", -1);
|
||||
if (parts.length != 4) return value;
|
||||
return parts[0] + "." + parts[1] + "." + stars(3) + "." + parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 마스킹: 마지막 그룹(113~128 비트) 마스킹
|
||||
* <pre>21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A → 21DA:00D3:0000:2F3B:02AA:00FF:FE28:****</pre>
|
||||
*/
|
||||
public static String maskIpv6(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
int lastColon = value.lastIndexOf(':');
|
||||
if (lastColon < 0) return value;
|
||||
return value.substring(0, lastColon + 1) + stars(4);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹: IPv4/IPv6 자동 판별, 쉼표 구분 다중 IP 지원
|
||||
* <pre>
|
||||
* 100.200.123.45 → 100.200.***.45
|
||||
* 21DA:...FE28:9C5A → 21DA:...FE28:****
|
||||
* 192.168.1.1,10.0.0.1 → 192.168.***.1,10.0.***.1
|
||||
* </pre>
|
||||
*/
|
||||
public static String maskIpAddress(String value) {
|
||||
if (value == null || value.isEmpty()) return value;
|
||||
String[] ips = value.split(",");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < ips.length; i++) {
|
||||
if (i > 0) sb.append(',');
|
||||
String ip = ips[i].trim();
|
||||
if (ip.contains(":")) {
|
||||
sb.append(maskIpv6(ip));
|
||||
} else {
|
||||
sb.append(maskIpv4(ip));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// private helpers
|
||||
// =========================================================================
|
||||
|
||||
private static String stars(int count) {
|
||||
if (count <= 0) return "";
|
||||
char[] arr = new char[count];
|
||||
Arrays.fill(arr, MASK);
|
||||
return new String(arr);
|
||||
}
|
||||
|
||||
private static String maskTailChars(String value, int count) {
|
||||
if (value.length() <= count) return stars(value.length());
|
||||
return value.substring(0, value.length() - count) + stars(count);
|
||||
}
|
||||
|
||||
private static String maskSegmentTail(String segment, int count) {
|
||||
if (segment.length() <= count) return stars(segment.length());
|
||||
return segment.substring(0, segment.length() - count) + stars(count);
|
||||
}
|
||||
|
||||
/** keyword 바로 앞에 연속된 숫자를 동일 자릿수의 '*'로 마스킹 */
|
||||
private static String maskDigitsBefore(String value, String keyword) {
|
||||
StringBuilder sb = new StringBuilder(value);
|
||||
int from = 0;
|
||||
while (true) {
|
||||
int kIdx = sb.indexOf(keyword, from);
|
||||
if (kIdx < 0) break;
|
||||
int end = kIdx;
|
||||
int start = end;
|
||||
while (start > 0 && Character.isDigit(sb.charAt(start - 1))) {
|
||||
start--;
|
||||
}
|
||||
for (int i = start; i < end; i++) {
|
||||
sb.setCharAt(i, MASK);
|
||||
}
|
||||
from = kIdx + keyword.length();
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** keyword 바로 뒤 공백 이후의 연속된 숫자를 동일 자릿수의 '*'로 마스킹 */
|
||||
private static String maskDigitsAfter(String value, String keyword) {
|
||||
StringBuilder sb = new StringBuilder(value);
|
||||
int from = 0;
|
||||
while (true) {
|
||||
int kIdx = sb.indexOf(keyword, from);
|
||||
if (kIdx < 0) break;
|
||||
int pos = kIdx + keyword.length();
|
||||
while (pos < sb.length() && sb.charAt(pos) == ' ') pos++;
|
||||
int start = pos;
|
||||
while (pos < sb.length() && Character.isDigit(sb.charAt(pos))) pos++;
|
||||
for (int i = start; i < pos; i++) {
|
||||
sb.setCharAt(i, MASK);
|
||||
}
|
||||
from = kIdx + keyword.length();
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user