Skip to main content

수문장 (sumunjang)

CI PyPI Python License

AI 도구가 해외 API 서버로 보내는 요청에서 한국 개인정보를 찾아 가려 주는 로컬 게이트웨이입니다. 돌아온 답변에서는 원문으로 되돌려 주므로, 사용자 화면에는 평소와 똑같이 보입니다.

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

왜 필요한가

Claude Code 같은 AI 코딩 도구에 장애 로그를 분석해 달라고 하면, 로그 안의 주민등록번호와 카드번호가 그대로 해외 서버로 갑니다.

붙여넣기만 조심하면 되는 문제가 아닙니다. 실제로 저희가 측정해 보니, 사용자가 입력한 것이 파일 이름 한 줄뿐인데도 한 번의 요청에 개인정보 8건이 실려 나갈 뻔했습니다. AI 도구가 사용자를 대신해 파일을 읽고 명령을 실행한 뒤 그 결과를 대화에 담아 보내기 때문입니다. 사용자는 자기가 무엇을 보내는지 화면에서 볼 수 없습니다.

개인정보보호위원회의 「생성형 AI 서비스 이용자를 위한 개인정보 보호 가이드」 (2026.5)는 "이름은 가명으로, 숫자는 XXX로" 바꿔 입력하라고 권고합니다. 사람이 손으로 지우라는 뜻인데, 위와 같은 경우에는 지울 기회 자체가 없습니다.

수문장은 그 일을 자동으로 합니다. 규칙으로 판별할 수 있는 것만 막고, 못 잡는 것은 한계에 적어 두었습니다.

설치와 실행

pip install sumunjang

가장 간단한 사용법은 run 입니다. 게이트웨이를 띄우고, 그 안에서 명령을 실행하고, 명령이 끝나면 문을 닫습니다.

sumunjang run -- claude

터미널 하나면 되고 환경변수를 직접 만질 일이 없습니다. 포트는 비어 있는 것을 자동으로 고릅니다.

run 은 화면에 아무것도 찍지 않습니다. Claude Code 처럼 터미널을 통째로 그리는 도구는 그 위에 한 줄만 얹혀도 화면이 깨집니다. 무엇을 가렸는지 보려면 --dump 로 파일에 적거나 --verbose 를 붙이세요.

sumunjang run --dump /tmp/보낸것.jsonl -- claude

runANTHROPIC_BASE_URLOPENAI_BASE_URL 을 자식 프로세스에 넘깁니다. 이 두 환경변수를 읽는 도구라면 그대로 붙습니다. Claude Code 와 OpenAI 공식 SDK 를 쓰는 도구가 여기 해당합니다.

설정 파일에 주소를 적어야 하는 도구 (Codex CLI 등)

Codex CLI 는 OPENAI_BASE_URL 을 읽지 않고 ~/.codex/config.tomlbase_url 을 봅니다. 게이트웨이를 따로 띄우고 그 주소를 적어 주세요.

sumunjang proxy --port 4000
model_provider = "sumunjang"

[model_providers.sumunjang]
name = "sumunjang"
base_url = "http://127.0.0.1:4000/v1"
env_key = "OPENAI_API_KEY"

게이트웨이를 따로 띄우기

sumunjang proxy --port 4000 --dump /tmp/보낸것.jsonl
export ANTHROPIC_BASE_URL=http://127.0.0.1:4000
claude

--dump 는 서버로 실제 나간 본문을 파일에 적습니다. 이미 가려진 본문이라 이 기록에는 원문이 남지 않습니다. 마스킹이 제대로 되는지 직접 확인할 때 쓰세요.

파일과 파이프에 바로 쓰기

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

$ sumunjang scan config.env          # 찾기만 합니다. 발견 시 종료 코드 1
$ sumunjang report                   # 자체 채점표를 출력합니다

scan 은 찾은 값을 화면에 찍지 않습니다. 카테고리와 위치만 알려 줍니다. CI 로그에 개인정보가 영구히 남는 것을 막기 위해서입니다. 값까지 보려면 --show-values 를 붙이세요.

커밋 전에 걸러내기

.pre-commit-config.yaml 에 세 줄을 더하면 개인정보가 든 파일의 커밋을 막습니다.

repos:
  - repo: https://github.com/HaChanho/sumunjang
    rev: v0.3.0
    hooks:
      - id: sumunjang

무엇을 가리는가

10종을 다룹니다. 판별 방법이 두 가지로 나뉩니다.

값 자체로 판별하는 것

번호에 검증식이나 정해진 형식이 있어서, 값만 보고 판단할 수 있는 경우입니다.

종류 판별 방법
주민등록번호 검증식(가중치 mod 11) 또는 생년월일 정합성
외국인등록번호 주민등록번호와 같은 체계. 7번째 자리가 5~8
사업자등록번호 검증식(가중치 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%를 얻으려면 오탐이 몇 배로 늘어납니다. 연·소·도·선·설 같은 희귀 성씨가 연락·소속·도착·선택·설정 의 첫 글자와 겹치기 때문입니다.

확실하지 않을 때는 조건을 더 봅니다

값이 스스로 증명하는 만큼만 조건을 덜어 줍니다.

경우 요구하는 것 이유
하이픈 있는 주민등록번호 검증식 또는 생년월일 하이픈 자체가 주민등록번호로 쓰려는 의도를 보여줍니다
하이픈 없는 주민등록번호 검증식 그리고 생년월일 13자리 숫자는 밀리초 타임스탬프·상품 바코드와 겹칩니다
국제 브랜드 카드 Luhn 검증 국제 규격을 따릅니다
국내전용 카드 구분자 표기 검증할 방법이 없어서, 사람이 옮겨 적은 흔적을 대신 봅니다

같은 기준이 앵커(앞에 오는 말)와 값 사이의 거리도 정합니다. 값이 스스로 증명하는 부분이 있으면 같은 줄 안 어디에 있어도 됩니다. 계좌: 국민은행 110-234-567890 처럼 은행 이름이 끼어드는 쪽이 오히려 보통이기 때문입니다. 값이 아무것도 증명하지 못하면 키: 값 형태를 요구합니다.

이 구분을 처음에는 두지 않았다가 오탐 세 건을 만들었습니다. 여권 발급 대기열 A00000001, 이체 수수료 정산 2026080612345678, 카드 결제 모듈 롤백 커밋 9410123456789012. 셋 다 앵커 근처에 우연히 놓인 숫자였고, 지금은 오탐 테스트 문서에 넣어 두었습니다.

한국에서만 나오는 경우들

2020년 10월 이후 발급된 주민등록번호는 뒷자리가 임의번호라서 검증식을 통과하지 않습니다. 검증식만 조건으로 두면 그 이후 발급분을 통째로 놓치기 때문에, 생년월일이 실재하는 날짜인지를 따로 봅니다.

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

표기가 달라도 잡습니다

하이픈 없이 붙여 쓴 주민등록번호(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]ᅧᆫ 으로 반쯤 남았습니다. 지금은 시작과 끝을 모두 기록합니다.

어디에 붙는가

프로토콜 3종을 지원합니다.

경로 프로토콜 대표 도구 보내는 곳
/v1/messages, /v1/messages/count_tokens Anthropic Messages Claude Code api.anthropic.com
/v1/chat/completions OpenAI Chat Completions OpenAI SDK 계열 api.openai.com
/v1/responses OpenAI Responses Codex CLI api.openai.com
/v1/models (본문 없음) 자격증명 헤더로 판단
그 밖의 모든 경로 차단

경로마다 보내는 곳이 정해져 있어서 게이트웨이 하나가 두 벤더를 모두 받습니다. --upstream 을 주면 모든 요청이 그쪽으로 갑니다. 사내 게이트웨이나 테스트용 서버를 앞에 둘 때 씁니다.

/v1/models 는 본문이 없어서 어느 프로토콜인지 알 수 없습니다. 자격증명 헤더의 모양이 유일한 단서라, Anthropic 이 쓰는 x-api-key 가 있으면 Anthropic 으로, OpenAI 가 쓰는 Authorization: Bearer 가 있으면 OpenAI 로 보냅니다.

/v1/responses 는 나중에 붙였습니다. Codex CLI 를 물려 보니 404 가 났고, 그때야 이 경로가 없다는 것을 알았습니다. OpenAI 는 2026년 2월 Codex 에서 Chat Completions 지원을 없앴습니다.

자체 평가

세 벌의 문서로 스스로 채점하고 결과를 공개합니다.

문서 묶음 무엇을 보는가 현재 점수
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월 이후 주민등록번호와, 앵커 없이 붙여 쓴 국내전용 카드는 놓칩니다.
  • 이름에는 오탐이 남아 있습니다. 담당: 배송팀 처럼 성씨로 시작하는 세 글자 낱말이 키: 값 자리에 오면 이름으로 읽습니다. 개인정보가 아닌 값을 가리는 쪽이 이름을 흘리는 쪽보다 낫다고 보고 이렇게 두었습니다.
  • Luhn 은 오탐을 완전히 막지 못합니다. 전치·오타를 잡는 용도라 1111-2222-3333-4444 같은 규칙적인 숫자도 통과합니다.
  • 구분자 없이 이어 붙인 식별자는 잡지 못합니다. 유효한 주민등록번호를 여러 개 연달아 붙이면, 앞뒤에 숫자가 붙어 있을 때 더 긴 번호의 일부로 보는 조건 때문에 탐지되지 않습니다. 그 조건이 없으면 타임스탬프와 주문번호가 전부 걸립니다.
  • 요청 헤더에 개인정보가 있으면 요청을 거부합니다. 가려서 보내지 않는 이유는 가명 표시가 한글이라 HTTP 헤더로 인코딩되지 않기 때문입니다. 자격증명과 프로토콜 협상 헤더(x-api-key, anthropic-version 등)는 그대로 넘깁니다.
  • 첨부 파일 안은 보지 않습니다. base64 로 실린 이미지·문서의 내용은 해독하지 않으므로 그 안의 개인정보는 그대로 나갑니다.
  • 도구 설명에 적힌 예시 값도 가려집니다. MCP 도구가 사용법을 설명하며 적어 둔 jane.doe@example.com 같은 예시가 [이메일_3] 이 되어 모델에게 갑니다. 도구 설명은 원문이 그대로 나가던 경로라서 가리는 쪽이 맞지만, 예시로 형식을 알려 주던 몫은 그만큼 줄어듭니다.
  • 숫자로 들어온 값이 개인정보로 보이면 문자열로 바뀝니다. 16자리 숫자 ID 의 약 5%, 밀리초 타임스탬프의 약 0.5% 가 여기 해당합니다. 로그·CSV 를 JSON 으로 옮기는 경로에서 식별자가 숫자형으로 들어오기 때문에 훑지 않을 수 없습니다.
  • 같은 API 키를 쓰는 여러 대화는 한 세션을 공유합니다. 대화 B 의 응답에 대화 A 가 만든 가명 표시가 들어 있으면 A 의 원문으로 복원됩니다. 같은 값에 같은 이름을 주려면 세션을 공유해야 해서 감수한 부분이고, 다른 자격증명 사이에서는 일어나지 않습니다.
  • 세션 상한 1만 건에 닿으면 요청을 거부합니다. 오래된 값을 버리는 방식이었는데, 버린 값은 다음 턴에 다시 가려지지 않아 그대로 나갔습니다. 대화 기록은 매 턴 다시 전송되기 때문입니다.
  • 세션에 있는 값이 다른 낱말과 겹치면 지나치게 가려집니다. 두 글자 이름 이준 이 세션에 있으면 이준비 완료[이름_1]비 완료 가 됩니다. 앞쪽 경계만 보기 때문인데, 뒤쪽까지 막으면 김수현씨·김수현 책임 처럼 조사와 직함이 붙은 형태를 놓칩니다.
  • 큰 본문을 처리하는 동안 다른 요청이 기다립니다. 마스킹이 동기 CPU 작업이라 그 시간만큼 이벤트 루프가 멈춥니다. 개인정보가 촘촘한 본문(110바이트마다 4건) 으로 재 보면 세션이 비어 있을 때 400KB 에 0.15초, 세션이 상한 근처(9,900건) 일 때 0.54초입니다. 스레드로 빼면 풀리지만 그러면 마스킹 코드에 동시성이 들어오므로, 지금은 단일 스레드를 유지하고 이 한계를 적어 둡니다.
  • 에이전트가 파일을 쓸 때 가명 표시가 그대로 박힐 수 있습니다. 모델이 도구에게 건네는 인자는 복원하지 않습니다. 여기를 복원하면 모델이 부르는 모든 도구가 원문을 받는데, Write·Bash 는 사용자 기계에서 돌지만 WebFetch 나 MCP 서버 호출은 다른 네트워크 출구입니다. 프록시는 도구 이름만 알 뿐 그것이 로컬인지 원격인지 알 방법이 없습니다. 실제 왕복 33회에서는 한 번도 발생하지 않았습니다.
  • 모델이 가명 표시를 설명 대상으로 언급하면 문장이 왜곡됩니다. 모델이 "마스킹 표시([주민등록번호_1])가 붙어 있었다"고 말하면 그 자리도 원문으로 되돌아갑니다. 값으로 쓴 것인지 표시를 설명한 것인지 구분하려면 문맥 판단이 필요해서 지금 구조로는 풀 수 없습니다.
  • Responses 경로에서 추론 요약은 가명 표시인 채로 남습니다. 모델이 만든 추론 요약은 되돌리지 않습니다. Anthropic 쪽에서 thinking 블록을 되돌리지 않는 것과 같은 판단입니다.
  • OpenAI 경로는 실제 서비스까지 왕복하지 않았습니다. 자세한 내용은 실환경 검증 기록에 등급으로 나눠 적었습니다.

설계 기준

자세한 내용과 그렇게 정한 이유는 DESIGN.md 에 적었습니다.

  • 개인정보가 지나가는 코드에는 외부 라이브러리를 쓰지 않습니다. 탐지와 마스킹은 파이썬 표준 라이브러리만 씁니다. 직접 의존성은 httpxuvicorn 둘뿐입니다 (SBOM.md).
  • 같은 입력이면 언제나 같은 결과가 나옵니다. AI 모델을 쓰지 않아서 실행할 때마다 답이 달라지지 않고, API 키도 네트워크도 필요 없습니다.
  • 본문은 전부 가리고 예외만 둡니다. 가릴 자리를 목록으로 관리했더니 목록에 없는 자리로 계속 샜습니다.
  • 예외인지 판단할 때는 요청자가 쓸 수 없는 근거만 봅니다. 요청 본문에 적어 두기만 하면 통과하는 조건은 예외가 아니라 우회 수단이 됩니다.
  • 한 번 가린 값은 문맥이 바뀌어도 계속 가립니다. 복원이 값을 탐지기가 알아볼 수 없는 자리로 옮겨 놓아 다음 턴에 새어나간 적이 있습니다.
  • 모르는 것은 통과시키지 않습니다. 표에 없는 경로는 서버에 닿지 않고, 마스킹 중에 오류가 나면 요청을 버립니다.

실제 AI 도구로 왕복시켜 확인한 기록도 DESIGN.md 에 있습니다. 네 번 했고 네 번 모두 자체 테스트가 잡지 못한 결함이 나왔습니다.

개발

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

테스트 187개가 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

sumunjang-0.3.0.tar.gz (129.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sumunjang-0.3.0-py3-none-any.whl (70.1 kB view details)

Uploaded Python 3

File details

Details for the file sumunjang-0.3.0.tar.gz.

File metadata

  • Download URL: sumunjang-0.3.0.tar.gz
  • Upload date:
  • Size: 129.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sumunjang-0.3.0.tar.gz
Algorithm Hash digest
SHA256 a39af0d288d30a30e42dac6dc81a2baed42fe7d148f3f7d4483505d62d48825a
MD5 ead4c530db14a0bc17f616cbde2ab2b4
BLAKE2b-256 786e5fb57094d103b40bf19cb52cc17e4171dc9a0cf06857635e728935d2ab6d

See more details on using hashes here.

Provenance

The following attestation bundles were made for sumunjang-0.3.0.tar.gz:

Publisher: release.yml on HaChanho/sumunjang

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sumunjang-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: sumunjang-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 70.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sumunjang-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f8bff800ba432a352b0aef7cf00aac1d60583ae71902dd4b6ad03c9faf405f37
MD5 00a0654ad23fae21366809701e1e9761
BLAKE2b-256 cc47de83936348ce03cd411ef48ef41f7aeae87cb86e7db872a415112adaceee

See more details on using hashes here.

Provenance

The following attestation bundles were made for sumunjang-0.3.0-py3-none-any.whl:

Publisher: release.yml on HaChanho/sumunjang

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page