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
2 changes: 1 addition & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* 로그에 남길 값에서 PII 를 가리는 유틸.
*
* <p><b>전역 로그 필터로 꽂지 말 것.</b> {@link #mask(String)} 를 Logback 컨버터 등으로 전
* 로그에 적용하면 전화번호 패턴이 구분자 포함 9자리 이상 숫자열을 전부 잡아 <b>트레이스 ID 와
* epoch millis 가 뭉개진다</b>:
*
* <pre>
* traceId=01234567-89ab-cdef-0123-456789abcdef
* → traceId=***-***-6789ab-cdef-***-***-6789abcdef
* </pre>
*
* <p>{@code X-Trace-Id} 상관관계는 Core·AI·RealTime 을 잇는 유일한 수단이라 이걸 잃는 대가가
* 더 크다. 그래서 지금 호출부가 없다 — 없어서 빠뜨린 게 아니라 안 거는 쪽을 택한 것이다.
*
* <p>쓰는 방법: 값이 PII 임을 <b>아는</b> 지점에서 {@link #maskEmail}·{@link #maskPhoneNumber}
* 같은 개별 함수를 직접 부른다. 애초에 본문을 로그에 남기지 않는 것이 1차 규약이다
* ({@code docs/security.md §7}).
*/
public final class PiiMasker {

private static final Pattern EMAIL_PATTERN = Pattern.compile(
Expand Down
Original file line number Diff line number Diff line change
@@ -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) **전역 필터로 꽂으면 안 되는 이유**를 함께 고정한다.
*
* <p>(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");
}
}
30 changes: 24 additions & 6 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()` 를 통째로 거는 용도가 아니다.
6 changes: 5 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ public class GithubTokenCipher {
- 사용자 답변 본문 (디버그 모드 한정, 운영은 마스킹)
- 신용카드 등 결제 정보 (해당사항 없음)

마스킹 대상은 `LoggingMasker` 유틸로 일괄 처리.
마스킹 유틸은 `common/log/PiiMasker` 다(문서에 있던 `LoggingMasker` 는 존재하지 않는 이름이었다).
**일괄 처리는 되지 않는다** — 로그 파이프라인에 필터로 꽂혀 있지 않고 호출부에서만 동작한다.
전역 적용이 왜 위험한지는 [`observability.md §9`](./observability.md) 참조(트레이스 ID 가 뭉개진다).

즉 위 목록은 "마스킹되니 남겨도 된다"가 아니라 **"남기지 않는다"** 는 규약이다.

---

Expand Down
Loading