Skip to content

fix(S15P11A705-205): GMS 오류 본문이 로그로 새는 경로를 원천에서 막는다 - #78

Merged
colosair merged 4 commits into
devfrom
fix/S15P11A705-205-log-redact
Jul 31, 2026
Merged

fix(S15P11A705-205): GMS 오류 본문이 로그로 새는 경로를 원천에서 막는다#78
colosair merged 4 commits into
devfrom
fix/S15P11A705-205-log-redact

Conversation

@colosair

Copy link
Copy Markdown
Member

요약

GMS 오류 응답 본문 200자가 예외 메시지를 타고 로그로 새던 경로를 원천에서 막습니다. -220이 예외가 HTTP 응답이 되는 지점을 막았고, 같은 문자열이 로그로도 나가는 것은 그 티켓 범위 밖이라 남아 있었습니다. 본문을 지우지 않고 마스킹합니다 — 400이 왜 났는지를 담은 유일한 단서라 무조건 지우면 진단 수단을 잃습니다. 도메인 코드·DB·API 계약 변경 없음.

Jira (필수)

  • 키 또는 URL: S15P11A705-205
  • 완료 조건: 세 벤더 경로의 오류 본문 실측 문서화 · 마스킹이 로그에 적용됨을 테스트로 보장 · 안 새는 벤더도 근거와 함께 기록 · ruff/pytest/coverage 게이트 유지 · docs/implements 리포트 + 색인 + WORKLOG

배경 — 티켓의 전제 둘이 실물과 달랐습니다

리뷰어가 먼저 보셔야 할 부분입니다.

① 남은 구멍이 「분류 밖 예외 → 트레이스백」 하나가 아니라 여섯입니다.

티켓은 *"분류 밖 예외는 여전히 500으로 나가며 트레이스백을 남긴다"*를 남은 경로로 지목했습니다. 맞지만 좁았습니다. 같은 예외 메시지가 분류가 정상 동작하는 경로에서도 로그가 됩니다.

# 위치 레벨
1 app/client/retry.py:107 WARNING재시도 1회마다 한 줄
2·3 app/service/embedding_service.py:64·:71 ERROR·WARNING
4·5 app/service/keyword_service.py:106·:111 ERROR·WARNING
6 uvicorn 트레이스백 ERROR

다섯이 %s로 예외 객체를 받습니다. 502 한 번이면 1번만으로 두 줄이 납니다. -221의 「애매한 것은 분류하지 않는다」와 무관하게 새고 있었습니다 — 그 판단은 건드리지 않았습니다.

failure-recovery.md §2.4 원칙 4가 참이 아니었습니다.

credential·endpoint·요청/응답 본문은 어느 레벨에서도 남기지 않습니다.

-197이 이 문장을 쓸 때 app.client.gms 로거만 보고 썼습니다. 그 로거는 실제로 본문을 싣지 않습니다. 그러나 같은 표에 있는 app.service.*·app.client.retry 행은 예외 객체를 그대로 받습니다. 명세와 코드가 어긋난 채였고 §2.6으로 갈라 정정했습니다.

실측 — 세 벤더가 오류 본문에 무엇을 담는가

실제 GMS로 네 경로(OpenAI·Gemini·Anthropic·임베딩)에 오류 19건을 의도적으로 만들어 응답 본문을 받았습니다. 401 케이스에는 진짜 키가 아니라 가짜 값을 넣었고, 스크립트는 세션 임시 디렉터리에만 두고 커밋하지 않았습니다. 응답에 진짜 키가 섞여 나오면 값을 지우고 에코됐다는 사실만 남기도록 짰습니다 — 한 건도 걸리지 않았습니다.

결과
자격 증명 네 경로 모두 에코하지 않는다. 401은 게이트웨이 고정 문구 — {"message":"[GMS 에러] Invalid or expired GMS key","statusCode":401}. 벤더에 도달하기 전에 GMS가 끊으므로 벤더별 차이가 생길 자리가 없다
endpoint 네 경로 모두 샌다. 단 URL이 아니라 맨 호스트Model not found in request for domain api.openai.com
게이트웨이 vs 벤더 티켓 추론대로 갈린다. [GMS 에러]는 6698바이트 고정 문구, [<Vendor> 에러]벤더 원문을 error 필드에 통째로 중첩(215433바이트)
사용자 Context 원문 값이 아니라 타입·경로만 되돌아온다(messages.0.content: Input should be…). PII가 통째로 새는 경로는 관측되지 않았다

요청 값이 잘려서 되돌아옵니다 — 이것이 실측의 핵심입니다. 요청 값에 마커를 심고 400을 유발했더니 OpenAI가 이렇게 답했습니다.

Invalid value: 'PIN...def'. Supported values are: 'system', 'assistant', 'user', …

앞뒤 3자만 남기고 잘라 에코합니다. 완전 일치 검사는 이것을 「에코 없음」으로 판정합니다(T61) — 하마터면 「요청 값은 안 샌다」로 결론 낼 뻔했습니다.

그래서 규칙의 근거를 「관측된 것을 지운다」가 아니라 「되돌아올 수 있는 자리를 막는다」로 잡았습니다. 오늘 자격 증명이 안 나온다는 사실은 게이트웨이 구현에 달렸고 우리가 통제하지 않습니다. 값이 잘려서라도 round-trip 한다는 것이 확인된 이상, 그 자리에 언젠가 자격 증명이 실릴 수 있다고 보는 편이 맞습니다. -220이 스텁으로 재현한 누출이 정확히 그 모양이었습니다.

변경 사항

  • app/core/redact.py (신규) — 마스킹 규칙의 정본. 자격 증명(sk-·AIza·JWT·Bearer·key=value)을 endpoint(URL·맨 호스트)보다 먼저 지웁니다. redact_body()redact(text[:4096])[:200]마스킹이 절단보다 먼저입니다.
  • app/client/embedding_client.py·llm_client.py — 예외 메시지 생성부 네 곳에 마스킹. resp.text[:200] 둘과, 응답 없는 실패의 {exc}. 후자는 llm_client의 기존 주석이 *"메시지 본문에는 URL이 섞여 들어올 수 있어 그쪽이 아니라 타입 이름을 쓴다"*고 적어 두고도 바로 다음 줄에서 같은 예외를 메시지에 넣고 있던 자리입니다.
  • app/smoke/gms_roundtrip.py — docstring만. 마스킹 도입 후에도 타입 이름만 내는 규칙을 유지하는 근거를 적었습니다(배포 파이프라인 로그는 보존·열람 범위가 더 넓습니다).
  • docs/spec/failure-recovery.md — §2.4 원칙 4 정정 + §2.6 신설(예외 메시지가 값이 로그에 닿는 경로라는 계약).
  • tests/test_log_redaction.py (신규, 18건) — 아래.
  • 문서implements 리포트(I34) · troubleshooting(T61~T63) · 색인 표 (implements 파일 표·I 전수 표 / troubleshooting 파일 표·T 전수 표) · WORKLOG.

테스트 / 검증

RED

마스킹 모듈을 넣기 전에 동작 단언 6건 RED를 확인했습니다. 실패 형태가 곧 위 §①의 표였습니다 — 예외 메시지·재시도 로그·전체 로그 행·트레이스백·전송 실패 메시지·구조 방어.

FAILED test_embedding_permanent_error_message_is_clean
FAILED test_judge_permanent_error_message_is_clean
FAILED test_retry_log_does_not_leak_response_body
FAILED test_all_gms_log_records_are_clean
FAILED test_transport_failure_message_is_clean
FAILED test_no_unredacted_response_body_in_app

GREEN

18건 GREEN. caplog으로 실제 로거 레코드를 받아 훑습니다.

무엇을 단언
자격 증명 5형태 redact() 후 값이 남지 않는다
endpoint — URL·맨 호스트 둘 다 지워지고 주변 문구는 남는다
실측한 GMS·벤더 본문 4종 마스킹을 통과해 원문 그대로다 (§3.5 근거)
200자 경계에 걸친 키 앞부분도 남지 않는다
PermanentError str(exc)트레이스백에 값이 없다
호출이 남긴 모든 로그 행 계측·재시도를 합쳐 전수로 훑는다
구조 방어 app/ 전체에 마스킹 없는 본문 접근이 없다(AST)

Regression

  • ruff check . — All checks passed
  • python -m compileall app tools — exit 0
  • pytest --cov=app --cov-branch404 passed, line 99.83% / branch 98.99%, tools/check_coverage_gate.py ok
  • DB 계약 변경 없음. pgvector Testcontainers는 전체 스위트에서 그대로 실행됨

-223(판정 n회 다수결)이 먼저 병합되어 keyword_service.py가 겹쳤습니다. git merge origin/dev로 로컬 해소 후 병합 전(375건)·후(404건)를 모두 돌려 회귀가 없음을 확인했습니다.

실행하지 않은 것

  • 실서버 대조를 하지 않았습니다. -220처럼 uvicorn 두 대를 띄워 로그를 눈으로 비교하는 절차는 밟지 않았습니다. 이 변경은 응답이 아니라 로그 문자열을 바꾸므로 caplog이 보는 것과 실서버가 내는 것이 같습니다. 대신 §실측을 실제 GMS로 했고 그 본문이 테스트 픽스처의 정본입니다.
  • 429 본문을 재지 못했습니다. 공용 게이트웨이를 의도적으로 밀어 429를 만들면 같은 키를 쓰는 다른 세션·시연에 영향이 갑니다. -197이 남긴 것은 상태 코드뿐이라 본문 형태는 미상입니다. [GMS 에러] 계열이든 벤더 중첩 계열이든 마스킹 규칙은 그대로 적용됩니다.

리뷰 포인트

  1. 막는 지점을 로그 호출부가 아니라 client로 잡은 것. 호출부는 여섯이고 그중 하나(트레이스백)는 애초에 호출부가 없습니다. 대안으로 root 로거 logging.Filter를 검토했으나 채택하지 않았습니다 — 모든 로그 행에 정규식을 물리는 비용이 상시로 들고 무관한 라이브러리 로그까지 건드립니다. 원천이 하나뿐인데 하류 전체를 검사할 이유가 없다는 판단이 맞는지 봐 주세요.
  2. 마스킹 경계 — 맨 호스트까지 지우는 것. … for domain api.openai.com… for domain <host>. 벤더 식별은 같은 시각 app.client.gms 행의 vendor=가 이미 하므로 진단이 줄지 않는다고 봤습니다. TLD 화이트리스트(com|io|net|org|ai|dev|kr|app|cloud)로 한정해 messages.0.content·gemini-2.5-flash 같은 평범한 식별자는 건드리지 않습니다. 이 목록이 충분한지가 판단이 갈릴 지점입니다.
  3. 마스킹이 절단보다 먼저인 것. 순서를 뒤집으면 200자 경계에 걸친 키의 앞부분이 남습니다. 잘린 자격 증명도 자격 증명이라는 것은 가정이 아니라 실측입니다(OpenAI가 값을 잘라 에코).
  4. 구조 방어 테스트를 AST로 둔 것. 텍스트로 훑었더니 gms_roundtrip.pydocstring이 위반으로 잡혔습니다(T63). 예외 목록을 손으로 유지하면 검사가 규약보다 약해지는 방향으로만 고쳐집니다.
  5. 자격 증명이 endpoint보다 먼저인 순서. https://host/?key=sk-…에서 URL 규칙이 먼저 걸리면 키가 통째로 <url>에 삼켜져 "무엇이 지워졌는지"가 사라집니다. 지워진 것이 키인지 경로인지는 대응이 갈리는 정보입니다(키면 재발급, 경로면 설정 수정).

리스크

  • 계약: failure-recovery.md §2.4 원칙 4를 정정하고 §2.6을 신설했습니다. HTTP 응답 본문 계약(§2.5의 고정 문구)은 그대로입니다 — 이 PR이 바꾸는 것은 로그뿐이고 back은 영향받지 않습니다.
  • 데이터·개인정보: Secret 값은 문서·커밋·테스트 픽스처 어디에도 없습니다. 픽스처의 가짜 자격 증명은 sk-NOT-A-REAL-KEY-USED-ONLY-IN-TESTS-0000처럼 한눈에 가짜로 지었습니다. 브랜치 diff를 .env 값 전부와 대조해 GMS_API_KEY·INTERNAL_SHARED_SECRET이 없음을 확인했습니다(걸린 셋은 .env.example과 기존 문서에 이미 공개된 GMS_BASE_URL·모델명 둘).
  • 운영·배포: 로그 문구가 바뀝니다. resp.text 원문을 grep하던 운영 스크립트가 있다면 영향을 받습니다 — 아는 한 없습니다. 마스킹은 알려진 패턴만 지우므로 미지의 형태로 실려 오는 자격 증명은 통과합니다(아래 후속).

범위 밖 / 후속

  • 운영 로그 조치는 이 티켓 밖입니다. 이미 남은 로그의 Loki 검색과 키 재발급 판단은 ai#69로 인프라 파트에 있습니다.
  • /metrics는 건드리지 않았습니다 — prod 승격 전 승인 항목(§2.4).
  • tools/는 범위 밖입니다. probe_vendors.py·demo_seed/_client.py 등이 r.text를 그대로 출력합니다. 사람이 손으로 돌리고 컨테이너 로그로 가지 않아 위험이 다릅니다. 구조 방어 테스트도 app/만 봅니다.
  • vendors.py_unwrap은 200 응답 봉투 파싱 예외를 싣습니다. 키 이름 같은 구조 정보라 위험이 낮다고 보고 뒀습니다. 모델 출력 값이 실릴 여지는 남습니다.
  • 거대 요청 본문에서 GMS가 오해를 부르는 400을 냅니다(T62) — Model not found in request for domain …인데 model은 멀쩡합니다. PermanentError로 분류돼 재시도 없이 FAILED가 되는데 문구는 모델명을 가리킵니다. 아주 긴 Context가 들어오면 오진 가능. 이 티켓 범위 밖이며 운영에서 실제로 나면 별도 티켓.
  • 후속 Jira: 없음(위 항목은 발생 시 신규 발행).

영구 문서

관련 GitHub Issue (선택)

  • ai#69 — 운영 로그 조치(인프라 소관)

🤖 Generated with Claude Code

colosair and others added 4 commits July 31, 2026 20:57
…bodies

GMS 오류 응답 본문 200자가 예외 메시지를 타고 로그로 나갔다. 같은 문자열이
retry.py 1곳·embedding_service 2곳·keyword_service 2곳에서 로그가 되고,
분류 밖 예외는 uvicorn 트레이스백으로도 나간다.

막는 지점을 로그 호출부가 아니라 예외 메시지가 만들어지는 곳으로 잡았다.
호출부마다 가리면 그 목록을 사람이 세야 하고 다음에 늘어나는 여섯 번째를 놓친다.

core/redact.py — 자격 증명(sk-·AIza·JWT·Bearer·key=value)을 endpoint(URL·맨 호스트)
보다 먼저 지운다. 마스킹 뒤에 절단한다 — 순서를 뒤집으면 200자 경계에 걸친 키의
앞부분이 남는다.

본문을 통째로 버리지 않는다. 400이 왜 났는지를 담은 유일한 단서다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…daction contract

failure-recovery.md §2.4 원칙 4 는 응답 본문을 "어느 레벨에서도 남기지 않습니다"
라고 적고 있었으나 코드는 그때도 남기고 있었다. §2.6 으로 갈라 정정한다.

실측 — 실제 GMS 로 네 경로에 오류 19건. 자격 증명은 한 건도 에코되지 않았고
(401 은 게이트웨이 고정 문구) endpoint 는 맨 호스트로 실린다. OpenAI 는 요청 값을
앞뒤 3자만 남기고 잘라 되돌린다.

I33 · T57~T59. 색인 표 둘 다 갱신.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-223 이 T57~T60 과 I33 을 먼저 썼다. 계약대로 origin/dev 를 당긴 뒤 번호를
확정하고 남의 행은 건드리지 않았다. 색인 표 둘(파일 표 · 전수 표)을 모두 채운다.

-223 병합 후 재검증 — pytest 404, line 99.83% / branch 98.99%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@colosair
colosair merged commit af72033 into dev Jul 31, 2026
3 checks passed
@colosair
colosair deleted the fix/S15P11A705-205-log-redact branch July 31, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant