Metadata-Version: 2.4
Name: sumunjang
Version: 0.1.0
Summary: AI API 경계에서 한국 개인정보를 막는 로컬 게이트웨이 — base_url 한 줄로 붙이는 온디바이스 마스킹 프록시
Project-URL: Homepage, https://github.com/HaChanho/sumunjang
Project-URL: Repository, https://github.com/HaChanho/sumunjang
Author: 문지기 (sumunjang)
License: Apache-2.0
License-File: LICENSE
Keywords: korean,llm,masking,pii,privacy,proxy,가명처리,개인정보
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Natural Language :: Korean
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 수문장 (sumunjang)

**AI에게 물어보기 전에, 개인정보는 문 앞에서 멈춘다.**

Claude Code나 ChatGPT에 로그·문서·설정 파일을 붙여넣는 순간, 주민등록번호·계좌번호·
API 키가 그대로 해외 API 서버로 전송됩니다. 개인정보보호위원회의 「생성형 AI 서비스
이용자를 위한 개인정보 보호 가이드」(2026.5)조차 *"이름은 가명으로, 숫자는 XXX로"* 라는
**수동 마스킹**을 권고할 뿐, 이를 자동화하는 무료 도구는 안내되지 않습니다.

수문장은 `base_url` 한 줄만 바꾸면 동작하는 **로컬 게이트웨이**입니다. 나가는 요청에서
한국 개인정보를 찾아 가명값으로 바꾸고, 돌아온 답변에서는 원문으로 되돌립니다.

```
당신          수문장                     Anthropic API
 │              │                            │
 │─ 900101-1234568 조회해줘 ─→               │
 │              │─ [주민등록번호_1] 조회해줘 ─→│   ← 서버는 원문을 본 적이 없다
 │              │←─ [주민등록번호_1]는 ... ──│
 │←─ 900101-1234568 는 ... ─│                │   ← 당신 화면에는 원문이 보인다
```

## 빠른 시작

```bash
# 게이트웨이 실행
uvx sumunjang proxy --port 4000

# Claude Code가 수문장을 거쳐 나가게 한다
export ANTHROPIC_BASE_URL=http://127.0.0.1:4000
claude
```

파일이나 파이프에도 바로 쓸 수 있습니다.

```bash
$ echo "고객 900101-1234568, 010-9876-5432, kim@corp.co.kr" | sumunjang mask -
고객 [주민등록번호_1], [전화번호_1], [이메일_1]

$ sumunjang scan config.env        # 찾기만 한다. 발견 시 종료 코드 1 → CI 게이트로 사용
$ sumunjang report                 # 골든셋으로 탐지 성능을 스스로 채점
```

## 무엇을 막는가

**검증식으로 판별하는 것** — 값 스스로가 자기가 무엇인지 증명합니다.

| 종류 | 검증 방식 |
|---|---|
| 주민등록번호 | 검증식(가중치 mod 11) **또는** 생년월일 정합성 |
| 사업자등록번호 | 검증식(가중치 1371 3713 5) |
| 카드번호 | 카드사 식별번호 대역 + 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`), 전각
숫자, 보이지 않는 문자(제로폭)를 끼워 넣은 우회까지. 제로폭은 정규화한 뒤 매칭하되
좌표는 원문 기준으로 되돌리므로 가려지는 위치가 어긋나지 않습니다.

**2020년 10월 이후 발급된 주민등록번호는 뒷자리가 임의번호**라 검증식을 통과하지 않습니다.
검증식만 관문으로 두면 그 세대를 통째로 놓치므로, 생년월일 정합성을 별도 관문으로 둡니다.

**국내전용 카드(9로 시작)는 Luhn을 따르지 않습니다.** 검증번호 산출 방식과 위치가
카드사마다 달라서입니다. Luhn만 관문으로 두면 한국 카드를 체계적으로 놓치므로 따로
다룹니다. 반대로 Luhn은 16자리 숫자 열 개 중 하나를 그냥 통과시키므로, 카드사 식별번호
대역이 아닌 숫자(전표번호 등)는 먼저 잘라냅니다.

### 증거가 없으면 관문을 더 요구한다

규칙이 열 종류지만 원칙은 하나입니다. **값이 스스로 증명하는 만큼만 관문을 덜어줍니다.**

| 상황 | 요구하는 것 | 이유 |
|---|---|---|
| 하이픈 **있는** 주민등록번호 | 검증식 **또는** 생년월일 | 하이픈 자체가 의도의 증거다 |
| 하이픈 **없는** 주민등록번호 | 검증식 **그리고** 생년월일 | 13자리는 밀리초 타임스탬프·상품 바코드와 겹친다 |
| 국제 브랜드 카드 | Luhn | 국제 규격을 따른다 |
| 국내전용 카드 | 구분자 표기 | 검증할 수단이 없다. 사람이 옮겨적은 흔적을 대신 본다 |

같은 원칙이 **앵커의 사정거리**도 정합니다. 값이 증거를 담고 있으면(검증식, 하이픈 묶음,
생년월일 관문) 앵커는 같은 줄 안에서 느슨해도 됩니다 — `계좌: 국민은행 110-234-567890`
처럼 은행 이름이 끼어드는 쪽이 오히려 보통이니까요. 반대로 값이 아무것도 증명하지
못하면(이름, 맨 숫자 계좌번호, 여권번호, 구분자 없는 국내전용 카드) `키: 값` 형태를
요구합니다.

이 구분을 처음엔 지키지 않았다가 오탐 셋을 만들었습니다 — `여권 발급 대기열 A00000001`,
`이체 수수료 정산 2026080612345678`, `카드 결제 모듈 롤백 커밋 9410123456789012`.
셋 다 앵커 근처에 우연히 놓인 숫자였습니다. 지금은 오탐 골든셋에 박혀 있습니다.

대가도 함께 적습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 발급분, 구분자 없는 국내전용
카드, 자유서술 속 이름과 주소는 **놓칩니다.** `goldenset-gaps/` 에 정답으로 박아 두고
점수로 공표합니다.

### 겹치는 탐지는 합집합으로 가린다

전화번호와 카드번호는 자릿수를 공유할 수 있습니다. `010-9921-3348-0000-0002` 에서
전화번호는 앞 13자, 카드번호는 뒤 19자를 차지합니다. 겹침을 "앞선 탐지가 이겼으니
뒤는 버린다"로 처리하면 카드번호 뒷 8자리가 평문으로 남습니다.

겹치는 탐지는 합집합 한 구간으로 병합해 **탐지기가 표시한 바이트를 하나도 남기지
않습니다.** 합쳐진 자리의 이름은 민감도 서열을 따릅니다 — 축은 *이 값 하나로 다른 문을
얼마나 열 수 있는가, 그 자물쇠를 바꿀 수 있는가* 하나입니다. 주민등록번호가 1위인 것은
한국 본인확인의 마스터키이면서 유출돼도 바꿀 수 없기 때문이고, 사업자등록번호가
최하위인 것은 국세청에서 공개 조회되기 때문입니다.

## 설계 원칙

**개인정보가 지나가는 경로에 서드파티 코드를 두지 않습니다.**
탐지·마스킹 모듈은 파이썬 표준 라이브러리만 사용합니다. 외부 패키지는 네트워크 계층에만
쓰이며 직접 의존성은 `httpx`, `uvicorn` 둘뿐입니다 ([SBOM.md](SBOM.md)).

**결정적입니다.** 같은 입력이면 언제나 같은 출력이고, API 키도 네트워크도 필요 없습니다.
AI 모델을 탑재하지 않으므로 실행할 때마다 결과가 달라지지 않습니다.

**조용히 실패하지 않습니다.** 복원은 정확히 일치하는 표시만 되돌립니다. 모델이 형태를
바꿔버린 경우 추측해서 복구하지 않습니다 — 잘못된 복원이 미복원보다 위험하기 때문입니다.

**스트리밍은 신뢰성을 택했습니다.** 조각난 응답에서 가명 표시를 복원하면 청크 경계에서
깨집니다. 그래서 업스트림에는 통짜로 요청해 복원을 끝낸 뒤, 클라이언트에게만 스트리밍
형태로 다시 흘려보냅니다. 첫 응답이 조금 늦는 대신 복원이 깨지지 않습니다.

## 실환경 검증 기록 (2026-08-06)

Claude Code를 수문장에 연결해 실제 API까지 왕복시켰습니다. 사용자가 입력한 것은
**파일 경로 한 줄**뿐입니다.

```
$ 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건"으로 보고해 작동이 멈춘 것처럼 보이던 문제입니다. 둘 다 저장소에
포함된 에코 서버로는 재현되지 않는 종류였습니다.

## 무엇을 못 잡는지도 점수로 냅니다

골든셋은 고치는 순간 포화됩니다. 어려운 문서를 넣어 점수를 떨어뜨려도, 드러난 결함을
고치고 나면 다시 만점이 되어 처음과 구분이 가지 않습니다. 만점 셋만 내놓으면 *"쉬운 것만
골라 놓고 만점이라 하는 것 아니냐"* 는 물음에 답할 수 없습니다.

그래서 **0점 셋을 나란히 공개합니다.**

| 셋 | 의미 | 현재 |
|---|---|---|
| `goldenset/` | 회귀 기준선. 깨지면 되던 것이 망가진 것 | 재현율 1.000 · 정밀도 1.000 |
| `goldenset-hard/` | 표기 변형과 오탐 함정 | 재현율 1.000 · 정밀도 1.000 |
| `goldenset-gaps/` | **못 잡는다고 선언한 것들.** 낮은 것이 정상 | 재현율 0.765 |

`goldenset-hard/` 는 도입 당시 **0.684 / 0.765** 였습니다. 그 자리가 드러낸 결함 넷을
고쳐 지금 수치가 됐습니다. `goldenset-gaps/` 는 **0.235에서 시작했습니다** — 문맥 앵커
규칙으로 계좌·여권·면허·이름과 좁혀 뒀던 관문 셋을 되찾아 0.765가 됐고, 남은 것은
자유서술 속 이름과 주소입니다. gaps 항목이 `hard` 로 옮겨가는 것이 곧 개선 기록입니다.

```bash
sumunjang report          # 세 셋을 나란히 채점
```

이 수치는 **독립적인 성능 증명이 아니라 회귀를 감시하는 기준선**입니다. 골든셋을 만든
사람이 도구도 만들었습니다.

## 한계

정직하게 적습니다.

- **자유서술 속 이름과 주소는 못 잡습니다.** 이름은 `성명: 최윤서` 같은 구조화된 표기만
  잡습니다. `어제 김수현 책임이랑 통화했는데` 는 앵커가 없어 규칙으로 판별할 수 없습니다.
  주소는 아예 다루지 않습니다 — 어디서 끝나는지를 규칙으로 정할 수 없습니다.
  둘 다 `goldenset-gaps/G2` 에 정답으로 박아 두고 0점으로 공표합니다.
- **앵커 없는 계좌·여권·면허번호도 못 잡습니다.** 검증식이 없거나(계좌) 공개되지
  않아서(면허 검사번호), 앞에 붙은 말이 유일한 근거입니다. `정산 항목 110-234-567890`
  처럼 앵커 없이 놓인 숫자는 계좌번호로 보지 않습니다.
- **관문을 좁힌 대가가 있습니다.** 앵커 없이 붙여 쓴 2020년 10월 이후 주민등록번호와,
  앵커 없이 붙여 쓴 국내전용 카드는 놓칩니다. 위 표의 `gaps` 셋에 들어 있습니다.
- **이름 탐지에는 남은 오탐 여지가 있습니다.** `담당: 배송팀` 처럼 성씨로 시작하는
  3자 낱말이 `키: 값` 자리에 오면 이름으로 읽힙니다. 개인정보가 아닌 값을 가리는
  쪽이 이름을 흘리는 쪽보다 낫다고 보고 이 방향을 택했습니다.
- **Luhn은 오탐을 완전히 막지 못합니다.** 전치·오타 검출용이라 `1111-2222-3333-4444`
  같은 규칙적 반복도 통과합니다. 카드사 식별번호 대역으로 한 겹 더 거르지만 그 안에서는
  여전히 통과합니다.
- **모델이 가명 표시를 값이 아니라 설명 대상으로 언급하면 복원이 문장을 왜곡합니다.**
  모델이 "마스킹 표시(`[주민등록번호_1]`)가 붙어 있었다"고 메타 언급하면 그 자리도
  원문으로 되돌아갑니다. 값으로 쓴 것인지 표시를 설명한 것인지 구분하려면 문맥 판단이
  필요해 현재 구조로는 완전히 풀 수 없습니다.
- 현재 Anthropic Messages API(`/v1/messages`)만 지원합니다.

## 개발

```bash
uv venv && uv pip install -e ".[dev]"
uv run pytest
```

테스트는 API 키도 네트워크도 없이 돕니다. 업스트림은 저장소에 포함된 에코 서버를 실제
ASGI 앱으로 띄워 검증하므로, 누구나 같은 결과를 재현할 수 있습니다.

`tests/test_leak.py` 는 다른 테스트와 성격이 다릅니다. 카테고리도 좌표도 묻지 않고
**"골든셋의 정답 값이 마스킹 결과에 부분 문자열로도 남아 있지 않은가"** 하나만 검사합니다.
재현율·정밀도는 스팬이 정확히 일치했는지를 세므로 "절반만 가렸다"는 잡아도 *남은 절반이
평문으로 나갔다*는 사실 자체는 드러내지 못합니다. 실제로 그 틈으로 결함이 하나 빠져나간
적이 있습니다.

## 라이선스

Apache License 2.0

---

2026 오픈소스 개발자대회 출품작 (팀 문지기)
