수문장 (sumunjang)
AI에게 물어보기 전에, 개인정보는 문 앞에서 멈춘다.
Claude Code나 ChatGPT에 로그·문서·설정 파일을 붙여넣는 순간, 주민등록번호·계좌번호· API 키가 그대로 해외 API 서버로 전송됩니다. 개인정보보호위원회의 「생성형 AI 서비스 이용자를 위한 개인정보 보호 가이드」(2026.5)조차 "이름은 가명으로, 숫자는 XXX로" 라는 수동 마스킹을 권고할 뿐, 이를 자동화하는 무료 도구는 안내되지 않습니다.
수문장은 base_url 한 줄만 바꾸면 동작하는 로컬 게이트웨이입니다. 나가는 요청에서
한국 개인정보를 찾아 가명값으로 바꾸고, 돌아온 답변에서는 원문으로 되돌립니다.
규칙으로 판별할 수 있는 것만 막습니다. 무엇을 못 잡는지는 한계에 적었고, 그중 상당수는 점수로도 공개합니다.
당신 수문장 Anthropic API
│ │ │
│─ 900101-1234568 조회해줘 ─→ │
│ │─ [주민등록번호_1] 조회해줘 ─→│ ← 서버는 원문을 본 적이 없다
│ │←─ [주민등록번호_1]는 ... ──│
│←─ 900101-1234568 는 ... ─│ │ ← 당신 화면에는 원문이 보인다
빠른 시작
# 게이트웨이 실행
uvx sumunjang proxy --port 4000
# Claude Code가 수문장을 거쳐 나가게 한다
export ANTHROPIC_BASE_URL=http://127.0.0.1:4000
claude
OpenAI 호환 클라이언트도 같은 게이트웨이를 씁니다.
export OPENAI_BASE_URL=http://127.0.0.1:4000
sumunjang proxy --upstream https://api.openai.com
Codex CLI는 ~/.codex/config.toml에 제공자를 하나 더 두면 됩니다. Codex는
2026년 2월부터 Responses API만 쓰므로 wire_api는 기본값 그대로 둡니다.
model_provider = "sumunjang"
[model_providers.sumunjang]
name = "sumunjang"
base_url = "http://127.0.0.1:4000/v1"
env_key = "OPENAI_API_KEY"
파일이나 파이프에도 바로 쓸 수 있습니다.
$ echo "고객 900101-1234568, 010-9876-5432, kim@corp.co.kr" | sumunjang mask -
고객 [주민등록번호_1], [전화번호_1], [이메일_1]
$ sumunjang scan config.env # 찾기만 한다. 발견 시 종료 코드 1 → CI 게이트로 사용
# 기본은 카테고리·좌표만 — 값은 --show-values 로
$ sumunjang report # 골든셋으로 탐지 성능을 스스로 채점
설치해서 쓰려면:
pip install sumunjang # 또는 uv pip install sumunjang
골든셋이 패키지에 함께 실려 있어, 저장소를 클론하지 않아도 sumunjang report 로
아래 수치를 그대로 재현할 수 있습니다.
무엇을 막는가
검증식으로 판별하는 것 — 값 스스로가 자기가 무엇인지 증명합니다.
| 종류 | 검증 방식 |
|---|---|
| 주민등록번호 | 검증식(가중치 mod 11) 또는 생년월일 정합성 |
| 사업자등록번호 | 검증식(가중치 1371 3713 5) |
| 카드번호 | 자릿수(13~19, ISO/IEC 7812) + 카드사 식별번호 대역 + Luhn (국내전용은 예외, 아래) |
| 휴대전화번호 | 통신사 식별번호 + 자릿수 (+82 표기 포함) |
| 이메일 | 형식 + 최상위 도메인 조건 |
| API 키·토큰 | Anthropic / OpenAI / GitHub / AWS / Slack 공표 접두사 |
문맥으로 판별하는 것 — 검증식이 없거나 공개되지 않아, 앞에 붙은 말을 증거로 씁니다.
| 종류 | 왜 형태만으로는 안 되는가 | 앵커 예 |
|---|---|---|
| 계좌번호 | 검증식이 없고 형식이 은행마다 다르다 (3-3-6, 6-2-6, 4-3-6 …) | 계좌:, 예금주, 입금 |
| 여권번호 | 영문 1자 + 숫자 8자. 제품 코드·대기번호와 겹친다 | 여권번호: |
| 운전면허번호 | 검사번호 산출식이 공개돼 있지 않다 | 면허: |
| 이름 | 형태 자체가 없다. 최윤서와 배송팀은 생김새가 같다 |
성명:, 예금주:, 담당: |
이름은 구조화된 문서 한정입니다. 성명: 최윤서처럼 키: 값 형태만 잡고,
어제 김수현 책임이랑 통화했는데 같은 자유서술 속 이름은 잡지 않습니다. 성씨 사전
(상위 50개, 인구 약 97%)으로 한 겹 더 거릅니다 — 커버리지가 아니라 오탐 때문입니다.
연·소·도·선·설 같은 희귀 성씨는 연락·소속·도착·선택·설정 의 첫 음절이기도 해서,
3%를 더 얻자고 오탐을 몇 배로 늘립니다.
표기 변형도 함께 받습니다. 하이픈 없이 붙여 쓴 주민등록번호(8803121000068), 점으로
구분한 전화번호(010.9876.5432), 국가번호를 붙인 전화번호(+82-10-2255-8830), 전각
숫자, 자모 분해(NFD) 표기, 보이지 않는 문자를 끼워 넣은 우회까지.
보이지 않는 문자는 범주로 한 겹, 목록으로 한 겹 막습니다. 목록만으로는 계속 뚫렸습니다 — U+200B를 막으면 U+2063으로, 그것을 막으면 U+00AD·U+2066으로 우회합니다. 목록은 언제나 공격자보다 늦습니다. 그래서 서식 제어 문자(Cf)와 폭 0 결합 문자(Mn)를 범주째 걷어냅니다.
다만 범주만으로도 닫히지 않습니다. 눈에 보이지 않는 것과 유니코드가 분류하는 방식이
일치하지 않기 때문입니다. U+3164 한글 채움 문자는 범주가 Lo(글자)인데 화면에는
아무것도 그리지 않고, 한국에서 공백 닉네임용으로 널리 쓰입니다. U+2800 점자 공백은
So, U+00A0은 Zs입니다. 어느 한쪽도 혼자서는 충분하지 않습니다.
정규화한 뒤 매칭하되 좌표는 원문 기준으로 되돌리므로 가려지는 위치가 어긋나지
않습니다. 되돌아가는 지도에는 시작과 끝을 둘 다 적습니다 — 시작만 적었더니
NFC가 세 코드포인트를 한 글자로 합친 자리에서 김수현이 [이름_1]ᅧᆫ으로
반쯤 남았습니다.
2020년 10월 이후 발급된 주민등록번호는 뒷자리가 임의번호라 검증식을 통과하지 않습니다. 검증식만 관문으로 두면 그 세대를 통째로 놓치므로, 생년월일 정합성을 별도 관문으로 둡니다.
국내전용 카드(9로 시작)는 Luhn을 따르지 않습니다. 검증번호 산출 방식과 위치가 카드사마다 달라서입니다. Luhn만 관문으로 두면 한국 카드를 체계적으로 놓치므로 따로 다룹니다. 반대로 Luhn은 16자리 숫자 열 개 중 하나를 그냥 통과시키므로, 카드사 식별번호 대역이 아닌 숫자(전표번호 등)는 먼저 잘라냅니다.
증거가 없으면 관문을 더 요구한다
규칙이 열 종류지만 원칙은 하나입니다. 값이 스스로 증명하는 만큼만 관문을 덜어줍니다.
| 상황 | 요구하는 것 | 이유 |
|---|---|---|
| 하이픈 있는 주민등록번호 | 검증식 또는 생년월일 | 하이픈 자체가 의도의 증거다 |
| 하이픈 없는 주민등록번호 | 검증식 그리고 생년월일 | 13자리는 밀리초 타임스탬프·상품 바코드와 겹친다 |
| 국제 브랜드 카드 | Luhn | 국제 규격을 따른다 |
| 국내전용 카드 | 구분자 표기 | 검증할 수단이 없다. 사람이 옮겨적은 흔적을 대신 본다 |
같은 원칙이 앵커의 사정거리도 정합니다. 값이 증거를 담고 있으면(검증식, 하이픈 묶음,
생년월일 관문) 앵커는 같은 줄 안에서 느슨해도 됩니다 — 계좌: 국민은행 110-234-567890
처럼 은행 이름이 끼어드는 쪽이 오히려 보통이니까요. 반대로 값이 아무것도 증명하지
못하면(이름, 맨 숫자 계좌번호, 여권번호, 구분자 없는 국내전용 카드) 키: 값 형태를
요구합니다.
이 구분을 처음엔 지키지 않았다가 오탐 셋을 만들었습니다 — 여권 발급 대기열 A00000001,
이체 수수료 정산 2026080612345678, 카드 결제 모듈 롤백 커밋 9410123456789012.
셋 다 앵커 근처에 우연히 놓인 숫자였습니다. 지금은 오탐 골든셋에 박혀 있습니다.
대가도 함께 적습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 발급분, 구분자 없는 국내전용
카드, 자유서술 속 이름과 주소는 놓칩니다. goldenset-gaps/ 에 정답으로 박아 두고
점수로 공표합니다.
한 번 가린 값은 문맥이 바뀌어도 계속 가린다
마스킹은 문맥에 기대지만(앵커) 복원은 문맥과 무관합니다. 이 비대칭이 유출을 만듭니다. 실제 왕복에서 잡힌 경로입니다.
① 도구가 파일을 읽음 담당자: 김수현 → 담당자: [이름_1] 가려짐
② 모델이 표시를 언급 "내용이 `[이름_1]` 같은 표시로…"
③ 프록시가 복원 "내용이 `김수현` 같은 표시로…" ← 앵커가 사라짐
④ 다음 턴에 기록 재전송 탐지기가 못 알아봄 업스트림 유출
복원이 값을 탐지기가 알아볼 수 없는 문맥으로 옮겨 놓습니다. 그래서 스스로를 증명하는 값(검증식·형식)은 살아남고 앵커에 기대는 값만 샜습니다 — 우연이 아니라 구조였습니다.
세션은 자기가 가린 값을 알고 있습니다. 규칙 탐지에 더해 세션이 아는 값을 함께 가려 고리를 닫습니다. 앵커가 없어도, 코드블록 안이어도, 모델이 설명 대상으로 언급해도 마찬가지입니다.
탐지와 재탐색이 같은 텍스트를 보고, 동일성을 정규형으로 판단해야 이 고리가 닫힙니다. 재탐색이 원문을 그대로 훑던 동안에는 제로폭 하나로 빠져나갔고, 세션이 원문 조각을 키로 삼던 동안에는 NFD로 처음 본 값이 자기를 못 알아봤습니다 — 프록시가 자기 출력을 다시 못 알아보는 자기 유발 유출이었습니다.
이 결함은 저장소에 포함된 에코 서버로는 재현되지 않습니다. 세 턴 이상의 대화와 실제 복원이 있어야 나타납니다.
겹치는 탐지는 합집합으로 가린다
전화번호와 카드번호는 자릿수를 공유할 수 있습니다. 010-9921-3348-0000-0002 에서
전화번호는 앞 13자, 카드번호는 뒤 19자를 차지합니다. 겹침을 "앞선 탐지가 이겼으니
뒤는 버린다"로 처리하면 카드번호 뒷 8자리가 평문으로 남습니다.
겹치는 탐지는 합집합 한 구간으로 병합해 탐지기가 표시한 바이트를 하나도 남기지 않습니다. 합쳐진 자리의 이름은 민감도 서열을 따릅니다 — 축은 이 값 하나로 다른 문을 얼마나 열 수 있는가, 그 자물쇠를 바꿀 수 있는가 하나입니다. 주민등록번호가 1위인 것은 한국 본인확인의 마스터키이면서 유출돼도 바꿀 수 없기 때문이고, 사업자등록번호가 최하위인 것은 국세청에서 공개 조회되기 때문입니다.
설계 원칙
개인정보가 지나가는 경로에 서드파티 코드를 두지 않습니다.
탐지·마스킹 모듈은 파이썬 표준 라이브러리만 사용합니다. 외부 패키지는 네트워크 계층에만
쓰이며 직접 의존성은 httpx, uvicorn 둘뿐입니다 (SBOM.md).
결정적입니다. 같은 입력이면 언제나 같은 출력이고, API 키도 네트워크도 필요 없습니다. AI 모델을 탑재하지 않으므로 실행할 때마다 결과가 달라지지 않습니다.
조용히 실패하지 않습니다. 복원은 정확히 일치하는 표시만 되돌립니다. 모델이 형태를 바꿔버린 경우 추측해서 복구하지 않습니다 — 잘못된 복원이 미복원보다 위험하기 때문입니다.
모르면 차단합니다 — 경로에서도, 본문에서도.
본문은 모든 문자열을 가리고 예외만 둡니다. 처음에는 "여기와 여기를 가린다"는
자리 목록을 두었는데, 그것은 화이트리스트처럼 보여도 실제로는 블랙리스트였습니다 —
아는 자리만 가린다는 곧 모르는 자리는 샌다는 뜻입니다. 리뷰에서 그 대가가
드러났습니다. Anthropic의 document 블록(사용자가 문서를 첨부하는 정식 경로),
tools[].description, metadata.user_id, OpenAI의 prediction.content,
messages[].name, 최상위 user, file.file_data가 전부 원문 그대로 나갔습니다.
게이트웨이 경로에는 "모르면 차단"을 적용해 놓고 본문에는 "모르면 통과"를 쓰고
있었던 셈입니다.
예외는 추론 블록과 base64로 실린 첨부의 알맹이 둘뿐입니다. 문자열만이 아니라
숫자와 사전의 키까지 훑습니다 — {"rrn": 8803121000068}처럼 숫자형으로 들어온
식별자가 그대로 나가던 자리입니다.
예외를 판정하는 방식이 중요합니다. 얕은 신호를 믿지 않습니다. 키 이름이나 값
접두사만으로 판정하다 세 번 뚫렸습니다 — signature라는 키면 어디서든, type: base64라고
적어두기만 하면 뭐든, data:로 시작하면 무엇이든 통과했습니다. 셋의 공통점은
공격자가 그 신호를 직접 쓸 수 있다는 것입니다. 그러면 그것은 예외 조건이 아니라
우회 스위치입니다.
그래서 근거를 요청자 바깥에서 찾습니다.
- 출처 — 우리가 그 값을 내보낸 적이 있는가. 추론 블록은 서명을 대조합니다. 요청자는 우리가 내보낸 적 없는 서명을 만들어낼 수 없습니다.
- 값 — 이 값에서 개인정보가 보이지 않는가. base64 첨부는 실제로 디코드되는지,
미디어 타입이 그림·소리·PDF인지, 그리고 탐지 규칙에 걸리지 않는지까지 봅니다.
디코드 여부만 물었을 때 뚫렸습니다 — base64 알파벳은 숫자와 영문자를 전부
포함하므로 구분자 없이 적은 한국 식별자와 API 키는 인코딩 없이도 조건을
만족합니다.
AKIAIOSFODNN7EXAMPLE…은 그 자체가 유효한 base64입니다. 흉내낼 수 있는 조건은 조건이 아닙니다.
추론 블록을 통째로 두는 이유는 두 가지입니다. 모델이 만든 글이라 사용자 원문이 있을 수 없고(인바운드가 모두 가려진 상태라 모델은 원문을 본 적이 없습니다), 서명이 본문을 보증하므로 본문만 가리면 서명이 보증하지 못하는 본문이 되어 다음 턴이 거부됩니다.
모델명·역할·식별자까지 훑지만 대개 손상되지 않습니다. 탐지기는 한국 개인정보처럼
생긴 것에만 반응하므로 claude-opus-4에는 아무 일도 일어나지 않습니다. 식별자에
개인정보가 들어 있어 업스트림이 거부한다면 그것은 눈에 보이는 실패입니다.
경로도 아는 것만 다룹니다.
| 경로 | 처리 |
|---|---|
/v1/messages, /v1/messages/count_tokens |
마스킹해서 전달 (Anthropic) |
/v1/chat/completions |
마스킹해서 전달 (OpenAI Chat Completions) |
/v1/responses |
마스킹해서 전달 (OpenAI Responses — Codex CLI가 쓰는 경로) |
/v1/models |
그대로 전달 — 본문이 없어 개인정보가 실릴 수 없음 |
| 그 밖의 모든 경로 | 차단. 업스트림에 닿지 않음 |
세 번째 경로는 실제로 막혀 봤기 때문에 있습니다. Codex CLI를 물렸더니 404가 났습니다 — 설계대로 동작한 것이지만, 그 결과 "OpenAI 호환" 이라 말하면서 OpenAI의 대표 코딩 에이전트를 통째로 못 받는 상태였습니다. OpenAI는 2026년 2월 Codex에서 chat/completions 지원을 완전히 제거했고, 마이그레이션 안내에 이렇게 적었습니다 — "조직이 LLM 프록시나 게이트웨이를 쓴다면 그것이 Responses API를 지원하는지 확인하라." 수문장이 정확히 그 게이트웨이입니다.
붙이는 데 든 것은 이 표의 줄 하나와 복원할 자리 하나입니다. 마스킹은 한 줄도
새로 쓰지 않았습니다 — 본문 전체를 훑는 방식이라 프로토콜을 모르기 때문입니다.
자리 목록을 두었다면 input[]·instructions·function_call_output.output을
하나씩 적어야 했고, 목록을 두 벌 관리한 대가가 이미 OpenAI 쪽 유출 넷이었습니다.
경로는 점 구간(..)을 먼저 접은 뒤에 비교합니다. /v1/models/../../v1/organizations/me는
접두사 검사를 통과하지만 HTTP 클라이언트가 정규화해 전혀 다른 엔드포인트를 사용자 키로
호출합니다. 통과 경로의 경로·쿼리도 마스킹합니다 — 본문이 없다는 것이 경로에 개인정보가
없다는 뜻은 아닙니다.
경로 비교는 구분자까지 봅니다 — startswith만 쓰면 /v1/models_backup 같은 미지
경로가 통과해 기본값이 무너집니다.
모르는 경로를 흘려보내면 마스킹을 거치지 않은 본문이 그대로 나갑니다. 마스킹 도중 예외가 나도 마찬가지로 요청을 버립니다 — 보안 도구의 실패 모드는 통과가 아니라 차단이어야 합니다.
마스킹 계층 자체는 프로토콜과 무관합니다. 프로토콜마다 다른 것은 본문의 어느 자리에 사람이 쓴 텍스트가 있는가 와 스트리밍 형식뿐이라, 새 프로토콜을 붙이는 일이 표에 줄 하나를 더하는 일이 되도록 두었습니다.
세 번째 프로토콜(Responses)을 붙이면서 이 말이 사실인지 확인했습니다. 마스킹은
한 줄도 새로 쓰지 않았습니다. 새로 정한 것은 복원할 자리 하나
(output[] 항목의 content[] 안)뿐이고, 그 규칙 하나가 셋을 동시에 갈랐습니다 —
output_text·refusal은 되돌리고, function_call.arguments와
reasoning.summary[]는 각각 다른 칸을 쓰므로 자연히 빠집니다. 나머지는 스트리밍
형식이었습니다.
스트리밍은 신뢰성을 택했습니다. 조각난 응답에서 가명 표시를 복원하면 청크 경계에서 깨집니다. 그래서 업스트림에는 통짜로 요청해 복원을 끝낸 뒤, 클라이언트에게만 스트리밍 형태로 다시 흘려보냅니다. 첫 응답이 조금 늦는 대신 복원이 깨지지 않습니다.
실환경 검증 기록
검증을 세 등급으로 나눠 적습니다. 무엇을 확인했고 무엇을 아직 안 했는지가 섞이면 안 되기 때문입니다.
| 등급 | 뜻 | 해당 |
|---|---|---|
| 실제 서비스 왕복 | 진짜 클라이언트 → 진짜 API 서버 | Anthropic Messages |
| 실제 클라이언트 왕복 | 진짜 클라이언트 → 로컬 대역 서버 | OpenAI Responses |
| 에코 fixture | 우리가 쓴 요청 모양만 | OpenAI Chat Completions |
에코 fixture는 매번 무언가를 놓쳤습니다. 세 번 시도해 세 번 다, 자체 테스트가 전부 통과하는 코드에서 새 결함이 나왔습니다.
Claude Code → api.anthropic.com (2026-08-06, 실제 서비스 왕복)
사용자가 입력한 것은 파일 경로 한 줄뿐입니다.
$ claude
> /tmp/결제장애-로그.txt 읽고 왜 결제가 실패했는지 분석해줘
수문장이 기록한 업스트림으로 실제 나간 본문:
user_rrn=[주민등록번호_1] ← 원문: 850312-1000003
user_phone=[전화번호_1]
user_email=[이메일_3]
card=[카드번호_1]
merchant_brn=[사업자등록번호_1]
ANTHROPIC_API_KEY=[시크릿_1]
사용자는 개인정보를 붙여넣은 적이 없습니다. 도구가 파일을 읽어 tool_result에 담는
순간 6종이 전송될 뻔했고, 여기에 도구가 시스템 프롬프트에 넣는 사용자 이메일까지 더해
한 요청에서 8건이 가려졌습니다. 업스트림 본문에 남은 원문은 0건이었습니다.
가려도 답변 품질이 떨어지지 않았습니다. 마스킹된 로그를 받은 모델은 장애 원인을 분석하면서 "로그에 크레덴셜이 평문으로 찍혀 있다"는 보안 문제까지 지적했습니다. 장애 분석에 필요한 것은 누구의 카드인지가 아니라 카드 결제가 실패했다는 구조이기 때문입니다.
이 검증 과정에서 자체 테스트로는 잡히지 않던 결함 두 건도 드러났습니다. 추론(thinking)
블록을 SSE 규약대로 delta로 흘리지 않아 다음 턴 요청이 400으로 거부되던 문제와,
반복되는 값을 "0건"으로 보고해 작동이 멈춘 것처럼 보이던 문제입니다. 둘 다 저장소에
포함된 에코 서버로는 재현되지 않는 종류였습니다.
Codex CLI 0.144.1 → 로컬 대역 서버 (2026-08-08, 실제 클라이언트 왕복)
클라이언트는 진짜이고 업스트림은 로컬 대역 서버입니다. api.openai.com까지의
왕복은 아직 아니므로 위 등급을 그대로 적습니다.
여기서 처음 확인한 것이 이 경로의 존재 이유였습니다. 물리자마자
404 지원하지 않는 경로: /v1/responses가 났습니다. 그래서 이 판에 붙였습니다.
붙인 뒤 두 턴이 돌았습니다. 사용자가 입력한 것은 역시 파일 이름 한 줄입니다.
1턴 Codex가 exec_command 로 `cat 결제장애-로그.txt` 를 실행
2턴 그 내용이 function_call_output 으로 되돌아옴 → 수문장이 가림
업스트림이 받은 것은 8종 13건의 가명 표시, 원문 0건입니다. 사용자 화면에는 원문이 돌아왔습니다 — 복원이 Responses SSE 경로에서도 깨지지 않았습니다.
에코 fixture로는 볼 수 없던 것을 셋 봤습니다.
- Codex의
instructions는 20,751자입니다. 시스템 프롬프트가 마스킹 대상이라는 것이 가정이 아니라 실측이 됐습니다. - 도구 이름이
shell이 아니라exec_command입니다. 우리가 상상해 쓴 요청 모양은 진짜와 다릅니다. - 도구 설명 속 예시 이메일까지 가려집니다.
tools[].description은 원문이 그대로 나가던 유출 경로였으므로 가리는 것이 의도이지만, 그 대가가 눈에 보이는 형태로 나타났습니다 (한계에 적었습니다).
무엇을 못 잡는지도 점수로 냅니다
골든셋은 고치는 순간 포화됩니다. 어려운 문서를 넣어 점수를 떨어뜨려도, 드러난 결함을 고치고 나면 다시 만점이 되어 처음과 구분이 가지 않습니다. 만점 셋만 내놓으면 "쉬운 것만 골라 놓고 만점이라 하는 것 아니냐" 는 물음에 답할 수 없습니다.
그래서 못 잡는다고 선언한 것들을 점수로 함께 공개합니다.
| 셋 | 의미 | 현재 |
|---|---|---|
goldenset/ |
회귀 기준선. 깨지면 되던 것이 망가진 것 | 재현율 1.000 · 정밀도 1.000 |
goldenset-hard/ |
표기 변형과 오탐 함정 | 재현율 1.000 · 정밀도 1.000 |
goldenset-gaps/ |
못 잡는다고 선언한 것들. 낮은 것이 정상 | 재현율 0.778 |
goldenset-hard/ 는 도입 당시 0.684 / 0.765 였습니다. 그 자리가 드러낸 결함 넷을
고쳐 지금 수치가 됐습니다. goldenset-gaps/ 는 0.235에서 시작했습니다 — 문맥 앵커
규칙으로 계좌·여권·면허·이름과 좁혀 뒀던 관문 셋을 되찾아 0.778이 됐고, 남은 것은
자유서술 속 이름과 주소입니다. gaps 항목이 hard 로 옮겨가는 것이 곧 개선 기록입니다.
sumunjang report # 세 셋을 나란히 채점
# CI 회귀 게이트 — 공표한 수치 아래로 내려가면 종료 코드 1
sumunjang report --only goldenset --only goldenset-hard \
--min-recall 1.0 --min-precision 1.0
점수를 찍기만 하면 회귀를 아무도 못 봅니다. 위 수치는 곧 계약이므로 CI가 매 푸시마다 그 계약을 지키는지 확인합니다.
이 수치는 독립적인 성능 증명이 아니라 회귀를 감시하는 기준선입니다. 골든셋을 만든 사람이 도구도 만들었습니다.
한계
정직하게 적습니다.
- 자유서술 속 이름과 주소는 못 잡습니다. 이름은
성명: 최윤서같은 구조화된 표기만 잡습니다.어제 김수현 책임이랑 통화했는데는 앵커가 없어 규칙으로 판별할 수 없습니다. 주소는 아예 다루지 않습니다 — 어디서 끝나는지를 규칙으로 정할 수 없습니다. 둘 다goldenset-gaps/G2에 정답으로 박아 두고 0점으로 공표합니다. - 앵커 없는 계좌·여권·면허번호도 못 잡습니다. 검증식이 없거나(계좌) 공개되지
않아서(면허 검사번호), 앞에 붙은 말이 유일한 근거입니다.
정산 항목 110-234-567890처럼 앵커 없이 놓인 숫자는 계좌번호로 보지 않습니다. - 관문을 좁힌 대가가 있습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 주민등록번호와,
앵커 없이 붙여 쓴 국내전용 카드는 놓칩니다. 위 표의
gaps셋에 들어 있습니다. - 이름 탐지에는 남은 오탐 여지가 있습니다.
담당: 배송팀처럼 성씨로 시작하는 3자 낱말이키: 값자리에 오면 이름으로 읽힙니다. 개인정보가 아닌 값을 가리는 쪽이 이름을 흘리는 쪽보다 낫다고 보고 이 방향을 택했습니다. - Luhn은 오탐을 완전히 막지 못합니다. 전치·오타 검출용이라
1111-2222-3333-4444같은 규칙적 반복도 통과합니다. 카드사 식별번호 대역으로 한 겹 더 거르지만 그 안에서는 여전히 통과합니다. - 구분자 없이 이어 붙인 식별자는 못 잡습니다.
88031210000688803121000068…처럼 유효한 주민등록번호를 연달아 붙이면, 앞뒤에 숫자가 붙어 있으면 더 긴 번호의 일부로 보는 경계 조건 때문에 탐지되지 않습니다. 그 조건이 없으면 타임스탬프· 주문번호가 전부 걸리므로 맞바꾼 대가입니다. - 요청 헤더에 개인정보가 있으면 요청을 거부합니다. 가려서 보내지 않는 이유는
가명 표시가 한글이라 HTTP 헤더로 인코딩되지 않기 때문입니다. 자격증명과 프로토콜
협상 헤더(
x-api-key,anthropic-version등)는 그대로 넘깁니다. - 첨부 파일 안은 보지 않습니다. base64로 실린 이미지·문서의 내용은 해독하지 않으므로, 그 안의 개인정보는 그대로 나갑니다. 마스킹 예외는 여기와 추론 서명, 도구 호출을 잇는 뿐입니다. 주소(URL)와 식별자는 한때 예외로 뒀다가 되돌렸습니다. 가리면 첨부가 깨지지만 깨지는 것은 눈에 보이고 유출은 보이지 않기 때문입니다.
- 숫자로 들어온 값이 개인정보로 보이면 문자열로 바뀝니다. 16자리 숫자 ID의 약 5%, 밀리초 타임스탬프의 약 0.5%가 이에 해당합니다. 로그·CSV를 JSON으로 옮겨 붙이는 경로에서 식별자가 숫자형으로 들어오기 때문에 훑지 않을 수 없고, 훑으면 이 비율은 피할 수 없습니다. 형이 바뀌어 업스트림이 거부한다면 그것은 눈에 보이는 실패입니다.
- 같은 API 키를 쓰는 여러 대화는 한 세션을 공유합니다. 대화 B의 응답에 대화 A가 만든 가명 표시가 그대로 들어 있으면 A의 원문으로 복원됩니다. 같은 값에 같은 이름을 주려면 세션을 공유해야 하므로 감수한 대가이며, 다른 자격증명 사이에서는 일어나지 않습니다.
- 세션 상한 1만 건에 닿으면 요청을 거부합니다. 오래된 값을 버리는 방식이었는데, 버린 값은 다음 턴에 다시 가려지지 않아 그대로 유출됐습니다 — 대화 기록은 매 턴 다시 전송되므로 퇴출은 과거를 지우는 게 아니라 보호를 푸는 것이었습니다. 메모리 상한과 유출 방지를 둘 다 지키려면 버리는 대신 거부해야 합니다.
- 세션은 인증 자격(API 키) 단위로 나뉩니다. 하나를 공유했더니 다른 대화의 개인정보가 주입됐습니다. 같은 자격의 여러 대화는 여전히 한 세션을 쓰며, 그건 의도입니다 — 같은 값에 같은 이름을 주어야 모델이 문맥을 잃지 않습니다.
- 세션이 아는 값이 다른 낱말과 겹치면 과도하게 가려집니다. 두 글자 이름
이준이 세션에 있으면이준비 완료가[이름_1]비 완료가 됩니다. 앞쪽 경계만 보기 때문인데, 뒤쪽까지 막으면김수현씨·김수현 책임처럼 조사·직함이 붙은 형태를 놓칩니다. 과도한 마스킹은 유출보다 안전한 쪽이라 이 방향을 택했습니다. - 큰 본문을 처리하는 동안 다른 요청이 기다립니다. 마스킹은 동기 CPU 작업이라 그 시간만큼 이벤트 루프가 멈춥니다. 개인정보가 촘촘히 박힌 본문(110바이트마다 4건)으로 잰 값입니다 — 세션이 비어 있으면 400KB에 0.15초, 상한 근처(9,900건)에서는 0.54초입니다. 비용은 본문 길이와 세션 크기 양쪽에 붙습니다. 정확성이 아니라 가용성의 한계이고, 스레드로 빼면 풀리지만 그러면 마스킹 코어에 동시성이 들어옵니다 — 지금 코어가 단일 스레드라서 얻는 것(같은 입력이면 같은 출력, 경쟁 상태 없음)이 더 크다고 보고 두었습니다.
- 에이전트가 파일을 쓸 때 가명 표시가 그대로 박힐 수 있습니다. 모델이 도구에게
건네는 인자(
tool_use.input)는 복원하지 않습니다. 여기를 복원하면 모델이 부르는 모든 도구가 원문을 받는데,Write·Bash는 사용자 기계에서 돌지만WebFetch나 MCP 서버 호출은 다른 네트워크 출구입니다. 프록시는 도구 이름만 알 뿐 그것이 로컬인지 원격인지 알 방법이 없어, 모르면 복원하지 않는 쪽을 택했습니다. 다만 실제 왕복 33회에서는 한 번도 발생하지 않았습니다 — 에이전트는 내용을 자기 몸으로 통과시키는 대신cp나 변환 스크립트로 처리하는 편을 택합니다. - 모델이 가명 표시를 값이 아니라 설명 대상으로 언급하면 복원이 문장을 왜곡합니다.
모델이 "마스킹 표시(
[주민등록번호_1])가 붙어 있었다"고 메타 언급하면 그 자리도 원문으로 되돌아갑니다. 값으로 쓴 것인지 표시를 설명한 것인지 구분하려면 문맥 판단이 필요해 현재 구조로는 완전히 풀 수 없습니다. - 도구 설명 안의 예시 값도 가려집니다. MCP 도구가 사용법을 설명하며 적어 둔
jane.doe@example.com같은 예시가[이메일_3]이 되어 모델에게 갑니다.tools[].description은 원문이 그대로 나가던 유출 경로였으므로 가리는 쪽이 맞지만, 도구 설명이 예시로 형식을 알려주던 몫은 그만큼 줄어듭니다. 예시인지 진짜인지는 생김새로 구분할 수 없고, 진짜를 흘리는 쪽이 더 나쁩니다. - OpenAI 경로는 실제 서비스까지 왕복하지 않았습니다. Responses 경로는 진짜 클라이언트(Codex CLI)로 두 턴을 돌렸지만 업스트림은 로컬 대역 서버였고, Chat Completions 경로는 저장소에 포함된 에코 서버로만 시험했습니다. 실제 API 서버까지 왕복을 마친 것은 Anthropic 경로뿐입니다 — 실환경 검증 기록에 등급으로 나눠 적었습니다.
- Responses 경로에서 추론 요약은 가명 표시인 채로 화면에 남습니다. 모델이
만든 추론 요약(
reasoning.summary[])은 되돌리지 않습니다. Anthropic 쪽에서 thinking 블록을 되돌리지 않는 것과 같은 판단입니다. 값이 화면에서 가려져 보이는 것은 눈에 띄는 흠이고, 되돌려야 할 자리를 늘리는 것은 보이지 않는 위험입니다.
개발
uv venv && uv pip install -e ".[dev]"
uv run pytest
테스트는 API 키도 네트워크도 없이 돕니다. 업스트림은 저장소에 포함된 에코 서버를 실제 ASGI 앱으로 띄워 검증하므로, 누구나 같은 결과를 재현할 수 있습니다.
tests/test_leak.py 는 다른 테스트와 성격이 다릅니다. 카테고리도 좌표도 묻지 않고
"골든셋의 정답 값이 마스킹 결과에 부분 문자열로도 남아 있지 않은가" 하나만 검사합니다.
재현율·정밀도는 스팬이 정확히 일치했는지를 세므로 "절반만 가렸다"는 잡아도 남은 절반이
평문으로 나갔다는 사실 자체는 드러내지 못합니다. 실제로 그 틈으로 결함이 하나 빠져나간
적이 있습니다.
라이선스
Apache License 2.0
2026 오픈소스 개발자대회 출품작 (팀 문지기)
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sumunjang-0.2.0.tar.gz.
File metadata
- Download URL: sumunjang-0.2.0.tar.gz
- Upload date:
- Size: 135.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
40c3774ecde66a49d8ad7cb2bc2f6c72aac134292e7e8b945d9c4f0673376d09
|
|
| MD5 |
87ca36bdc803d37089ed0e8dbfd47a0e
|
|
| BLAKE2b-256 |
c39f4e2cfb95c4c97e82b78a6086ded1d08bec5a47b0ef316bd461a3690bae01
|
Provenance
The following attestation bundles were made for sumunjang-0.2.0.tar.gz:
Publisher:
release.yml on HaChanho/sumunjang
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sumunjang-0.2.0.tar.gz -
Subject digest:
40c3774ecde66a49d8ad7cb2bc2f6c72aac134292e7e8b945d9c4f0673376d09 - Sigstore transparency entry: 2402285684
- Sigstore integration time:
-
Permalink:
HaChanho/sumunjang@f2e628222f0b4d1eb7c518d16908b6c5f5c742bb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/HaChanho
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f2e628222f0b4d1eb7c518d16908b6c5f5c742bb -
Trigger Event:
push
-
Statement type:
File details
Details for the file sumunjang-0.2.0-py3-none-any.whl.
File metadata
- Download URL: sumunjang-0.2.0-py3-none-any.whl
- Upload date:
- Size: 72.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9040c00119a0535fadf9759d6a3d88bfaf8cf5d91c3c05d04150e7fd4cc09d29
|
|
| MD5 |
3612d587db4666b93031327e5f96037b
|
|
| BLAKE2b-256 |
5c080c92a6e6bd3cf670c75afad28d9749f0666b942926500d251358b9e7339a
|
Provenance
The following attestation bundles were made for sumunjang-0.2.0-py3-none-any.whl:
Publisher:
release.yml on HaChanho/sumunjang
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sumunjang-0.2.0-py3-none-any.whl -
Subject digest:
9040c00119a0535fadf9759d6a3d88bfaf8cf5d91c3c05d04150e7fd4cc09d29 - Sigstore transparency entry: 2402286120
- Sigstore integration time:
-
Permalink:
HaChanho/sumunjang@f2e628222f0b4d1eb7c518d16908b6c5f5c742bb -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/HaChanho
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f2e628222f0b4d1eb7c518d16908b6c5f5c742bb -
Trigger Event:
push
-
Statement type: