diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 3f84fb98..30d41640 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -222,7 +222,7 @@ spring.jpa.hibernate.ddl-auto=validate # Flyway 사용 → validate - Logback JSON 포맷 (운영) / human-readable (로컬) - MDC에 `traceId`, `userId` -- 민감정보 마스킹: `common/log/PiiMasker.java` +- 민감정보 마스킹 유틸: `common/log/PiiMasker.java` — **자동 적용 아님**(호출부에서만 동작, 현재 호출부 없음). 전역 필터로 꽂으면 트레이스 ID 가 뭉개진다: [`/docs/observability.md §9`](../docs/observability.md) - 자세한 정책: [`/docs/observability.md`](../docs/observability.md) --- diff --git a/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java b/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java index 232771c6..82062ff1 100644 --- a/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java +++ b/backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java @@ -3,6 +3,25 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * 로그에 남길 값에서 PII 를 가리는 유틸. + * + *

전역 로그 필터로 꽂지 말 것. {@link #mask(String)} 를 Logback 컨버터 등으로 전 + * 로그에 적용하면 전화번호 패턴이 구분자 포함 9자리 이상 숫자열을 전부 잡아 트레이스 ID 와 + * epoch millis 가 뭉개진다: + * + *

+ * traceId=01234567-89ab-cdef-0123-456789abcdef
+ *   → traceId=***-***-6789ab-cdef-***-***-6789abcdef
+ * 
+ * + *

{@code X-Trace-Id} 상관관계는 Core·AI·RealTime 을 잇는 유일한 수단이라 이걸 잃는 대가가 + * 더 크다. 그래서 지금 호출부가 없다 — 없어서 빠뜨린 게 아니라 안 거는 쪽을 택한 것이다. + * + *

쓰는 방법: 값이 PII 임을 아는 지점에서 {@link #maskEmail}·{@link #maskPhoneNumber} + * 같은 개별 함수를 직접 부른다. 애초에 본문을 로그에 남기지 않는 것이 1차 규약이다 + * ({@code docs/security.md §7}). + */ public final class PiiMasker { private static final Pattern EMAIL_PATTERN = Pattern.compile( diff --git a/backend/src/test/java/com/stackup/stackup/common/log/PiiMaskerTest.java b/backend/src/test/java/com/stackup/stackup/common/log/PiiMaskerTest.java new file mode 100644 index 00000000..d0287845 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/log/PiiMaskerTest.java @@ -0,0 +1,54 @@ +package com.stackup.stackup.common.log; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * PiiMasker 는 호출부에서만 쓰는 유틸이다. 여기서는 (1) 개별 마스킹이 의도대로 도는지와 + * (2) **전역 필터로 꽂으면 안 되는 이유**를 함께 고정한다. + * + *

(2)를 테스트로 남기는 이유: 문서에 적어두는 것만으로는 다음 사람이 Logback 컨버터로 + * 꽂는 걸 막지 못한다. 그렇게 하면 트레이스 ID 가 뭉개져 Core·AI·RealTime 로그 상관관계가 + * 통째로 깨지는데, 그건 로그를 봐야 발견된다. + */ +class PiiMaskerTest { + + @Test + void masksEmailKeepingDomain() { + assertThat(PiiMasker.maskEmail("hongildong@example.com")).isEqualTo("ho***@example.com"); + } + + @Test + void masksPhoneKeepingLastFour() { + assertThat(PiiMasker.maskPhoneNumber("010-1234-5678")).isEqualTo("***-***-5678"); + } + + @Test + void masksGithubTokenKeepingPrefixAndTail() { + assertThat(PiiMasker.maskGithubToken("ghp_abcdefghijklmnopqrstuvwxyz0123")) + .startsWith("ghp") + .endsWith("0123") + .contains("***"); + } + + @Test + void maskFindsPatternsInsideFreeText() { + String masked = PiiMasker.mask("문의: hongildong@example.com 로 연락 주세요"); + assertThat(masked).doesNotContain("hongildong@").contains("example.com"); + } + + // 아래 둘이 이 클래스를 전역 필터로 쓰면 안 되는 이유다 (클래스 Javadoc 참고). + // 실패한다면 전화번호 패턴이 개선된 것이므로, 그때 문서·Javadoc 의 근거도 함께 갱신한다. + @Test + void maskCorruptsTraceIds_soItMustNotBeAppliedGlobally() { + String traceId = "01234567-89ab-cdef-0123-456789abcdef"; + + assertThat(PiiMasker.mask("traceId=" + traceId)).doesNotContain(traceId); + } + + @Test + void maskCorruptsEpochMillis_soItMustNotBeAppliedGlobally() { + assertThat(PiiMasker.mask("endedAt=1755993600000")).doesNotContain("1755993600000"); + } +} diff --git a/docs/observability.md b/docs/observability.md index faf2278e..ed94a7bd 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -239,10 +239,28 @@ docker logs stackup-ai | grep '9f4e5b' ## 9. PII (Personal Identifiable Information) 마스킹 -운영 로그에 자동 마스킹할 패턴: -- 이메일: `***@***` -- 전화번호: `010-****-****` -- GitHub access token: `ghp_***` -- JWT: `eyJ***` +`backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java` 가 마스킹 함수를 제공한다. +지원 패턴: 이메일 / 전화번호 / GitHub access token / JWT. -`backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java` 단일 책임 클래스. +> **자동 마스킹은 걸려 있지 않다.** `PiiMasker` 는 **호출하는 곳에서만** 동작하는 유틸이고, +> 현재 호출부가 없다. 로그 파이프라인에 필터로 꽂혀 있지 않으므로 "로그에 남겨도 알아서 +> 가려지겠지"라고 가정하면 안 된다 — 애초에 남기지 않는 것이 규약이다(`docs/security.md §7`). + +### 왜 전역 필터로 꽂지 않았나 + +`PiiMasker.mask()` 를 Logback 컨버터로 전 로그에 적용하면 **분산 추적이 깨진다.** +전화번호 패턴이 구분자를 포함한 9자리 이상 숫자열을 모두 잡기 때문이다: + +``` +traceId=01234567-89ab-cdef-0123-456789abcdef + → traceId=***-***-6789ab-cdef-***-***-6789abcdef +session ended at 1755993600000 + → session ended at ***-***-0000 +``` + +`X-Trace-Id` 상관관계는 Core·AI·RealTime 을 잇는 유일한 수단이라(§1) 이걸 잃는 대가가 +"혹시 모를 PII"보다 크다. 지금 백엔드·AI 로그는 모두 **식별자만** 남기고 본문을 남기지 +않으므로(감사 확인) 전역 필터의 실익도 없다. + +**쓰는 방법**: 값이 PII 임을 아는 지점에서 `maskEmail`·`maskPhoneNumber` 같은 개별 함수를 +직접 부른다. 임의의 로그 문자열에 `mask()` 를 통째로 거는 용도가 아니다. diff --git a/docs/security.md b/docs/security.md index 8039be9b..f2eb94b0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -208,7 +208,11 @@ public class GithubTokenCipher { - 사용자 답변 본문 (디버그 모드 한정, 운영은 마스킹) - 신용카드 등 결제 정보 (해당사항 없음) -마스킹 대상은 `LoggingMasker` 유틸로 일괄 처리. +마스킹 유틸은 `common/log/PiiMasker` 다(문서에 있던 `LoggingMasker` 는 존재하지 않는 이름이었다). +**일괄 처리는 되지 않는다** — 로그 파이프라인에 필터로 꽂혀 있지 않고 호출부에서만 동작한다. +전역 적용이 왜 위험한지는 [`observability.md §9`](./observability.md) 참조(트레이스 ID 가 뭉개진다). + +즉 위 목록은 "마스킹되니 남겨도 된다"가 아니라 **"남기지 않는다"** 는 규약이다. ---