From 3a78e5c13879518338144b75195d809b1eb7e5d8 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 22 May 2026 17:36:05 +0900 Subject: [PATCH] fix: reveal plaintext service token on issue/rotate (HOTFIX-001) - render_service_token_result no longer masks the token field; plaintext tokens are SHA-256 hashed in DB and irrecoverable once lost, so issue/ rotate must reveal them once at issuance (matches interface-contract.md) - Remove now-unused _mask_payload helper; keep mask_token_text for the auxiliary masked_token identifier line - Add one-time reveal warning to issue/rotate table output - 3 regression tests: JSON plaintext assertion, render unit test, table warning - Hotfix Exception Path record: docs/hotfixes/hotfix-001 - Total tests: 369 -> 371 passed Co-Authored-By: Claude Opus 4.7 (1M context) --- app/cli/commands/service_tokens.py | 2 + app/cli/core/output.py | 13 +--- ...0260522_service-token-plaintext-masking.md | 67 +++++++++++++++++++ tests/test_cli.py | 52 ++++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md diff --git a/app/cli/commands/service_tokens.py b/app/cli/commands/service_tokens.py index 2d64fdd..47a792b 100644 --- a/app/cli/commands/service_tokens.py +++ b/app/cli/commands/service_tokens.py @@ -89,6 +89,7 @@ def issue_service_token( if ctx.obj.output_format != "json": ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") + ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]") @token_app.command("rotate") @@ -114,6 +115,7 @@ def rotate_service_token( if ctx.obj.output_format != "json": ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}") + ctx.obj.console.print("[yellow]⚠ 이 토큰은 다시 표시되지 않습니다. 지금 복사해 안전한 곳에 저장하세요.[/yellow]") @token_app.command("rebind") diff --git a/app/cli/core/output.py b/app/cli/core/output.py index fbf122b..e52cb03 100644 --- a/app/cli/core/output.py +++ b/app/cli/core/output.py @@ -14,17 +14,8 @@ def mask_token_text(raw_token: str) -> str: return mask_secret(raw_token, prefix=6, suffix=4) -def _mask_payload(payload: dict[str, Any]) -> dict[str, Any]: - masked = dict(payload) - token = masked.get("token") - if isinstance(token, str) and token: - masked["token"] = mask_token_text(token) - return masked - - def render_service_token_result(console: Console, output_format: str, payload: Any) -> None: + # 발급(issue/rotate) 결과는 평문 토큰을 1회 노출한다. 평문은 hash_token(SHA-256)으로만 + # DB에 저장되어 이후 복구가 불가능하므로, 발급 렌더링 경로에서는 마스킹하지 않는다. plain = payload.model_dump(mode="json") if hasattr(payload, "model_dump") else payload - if isinstance(plain, dict): - render_output(console, output_format, _mask_payload(plain)) - return render_output(console, output_format, plain) diff --git a/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md b/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md new file mode 100644 index 0000000..94878fe --- /dev/null +++ b/docs/hotfixes/hotfix-001_20260522_service-token-plaintext-masking.md @@ -0,0 +1,67 @@ +# HOTFIX-001: service-token issue/rotate 평문 토큰 마스킹 결함 + +- Status: verified (배포 대기) +- Date: 2026-05-22 +- Severity: High +- Area: CLI / 인증 (`pfhub service-token`) +- Source: portfolio-hub 운영팀 이슈티켓 (대상 버전 0.1.0, commit `58899b1`) +- Path: Hotfix Exception Path (CLAUDE.md — Requirements/Delivery 경량화, 4개 필수 항목 충족) + +## 1. 최소 요구사항 정의 + +### 증상 +- `pfhub service-token issue` / `rotate` 가 새 토큰을 발급하지만 출력 시 평문 토큰을 항상 마스킹(`ySusw9****Wa3w`)한다. +- `--format json` 을 지정해도 마스킹되며, 마스킹 해제 옵션이 없다. +- 결과적으로 새로 발급된 토큰의 평문을 확인할 방법이 전혀 없다. + +### 원인 +- `app/cli/core/output.py` `render_service_token_result()` 가 출력 포맷 분기 이전에 무조건 `_mask_payload()` 로 `token` 필드를 마스킹. +- `_mask_payload()` 는 `token` 키를 가진 응답에만 동작 → `token` 필드를 가진 응답은 `issue`/`rotate` 둘뿐 → 마스킹이 *토큰이 반드시 필요한 두 명령에서만* 작동. +- `--format` 분기보다 마스킹이 먼저 일어나 `table`/`json` 모두 마스킹됨. + +### 영향 +- 신규 토큰 사용 불가: 발급/회전 후 평문을 알 수 없어 클라이언트·데몬에 설정 불가. +- 데이터 소실(복구 불가): 토큰은 `app/core/security.py:45` `hash_token()`(SHA-256) + `token_prefix` 만 DB 저장. 평문은 명령 실행 중 메모리에만 존재. +- 운영 리스크: `rotate` 는 기존 토큰을 폐기하고 새 토큰을 발급 → 이 버그 상황에서 회전 시 *이전 토큰 무효화 + 새 토큰 미상* 상태. +- 명세 정합성: `docs/interface-contract.md` L83/L494 는 "토큰은 발급 시 1회 노출, 이후 마스킹"을 이미 명시 — 현 구현이 baseline 계약을 위반. **명세 결함이 아닌 구현 버그.** + +### 성공 조건 +- 결정된 노출 방식: **기본 1회 노출** (운영팀 제안 채택, 2026-05-22 확정). +- SC-1: `pfhub --format json service-token issue …` → JSON `token` 필드에 평문 토큰이 그대로 포함된다 (마스킹 문자 `*` 없음). +- SC-2: `pfhub --format json service-token rotate …` → 동일하게 평문 `token` 포함. +- SC-3: `table` 출력(`issue`/`rotate`) → 평문 토큰 1회 출력 + "이 토큰은 다시 표시되지 않습니다" 경고 표기. +- SC-4: `masked_token:` 보조 표시는 식별용으로 유지(table 경로). `mask_token_text()` 자체는 보존. +- SC-5: `list`/`rebind`/`revoke` 출력은 회귀 없이 기존 동작 유지(이들 응답에는 `token` 필드 없음). + +## 2. 최소 검증 + +- 재현/회귀 테스트: + - `tests/test_cli.py::test_cli_service_token_issue_supports_json` (확장) — JSON `token` 필드가 평문임을 단언 (`"*" not in token` + `token.startswith(stored.token_prefix)`) (SC-1). ✅ + - `tests/test_cli.py::test_render_service_token_result_does_not_mask_token` (신규 단위) — `render_service_token_result()`가 `token`을 평문으로 렌더링 (json/table 양쪽, 출력에 `*` 부재). rotate 경로는 동일 함수 호출이므로 본 단위 테스트가 SC-2 커버. ✅ + - `tests/test_cli.py::test_cli_service_token_issue_table_shows_reveal_warning` (신규) — table 출력에 token_prefix + "다시 표시되지 않습니다" 경고 문구 (SC-3). ✅ + - 회귀: 전체 pytest suite — `list`/`rebind`/`revoke` 테스트 무변동 (SC-5). ✅ +- 결과: + - `.venv/bin/python -m pytest tests/test_cli.py -k "service_token or render_service_token"` → **15 passed** + - `.venv/bin/python -m pytest` (전체) → **371 passed in 11.60s** (BAT-013 직후 369 baseline 대비 +2 신규, 회귀 0) +- 적용 변경: + - `app/cli/core/output.py` — `_mask_payload()` 함수 제거(발급 경로 외 사용처 없음), `render_service_token_result()`가 마스킹 없이 렌더링. `mask_token_text()`는 보존(`masked_token:` 보조 표시에서 계속 사용). + - `app/cli/commands/service_tokens.py` — `issue`/`rotate` non-json 출력에 "이 토큰은 다시 표시되지 않습니다" 1회 노출 경고 추가. + +## 3. 배포 전 롤백 계획 + +- 변경 범위: `app/cli/core/output.py`, `app/cli/commands/service_tokens.py` 2개 파일. DB 스키마·마이그레이션 변경 없음, HTTP API 변경 없음. +- 롤백 방식: 코드 전용 변경이므로 직전 이미지/커밋(`58899b1`)으로 재배포하면 즉시 원복. 데이터 마이그레이션 없음 → 롤백 시 데이터 리스크 없음. +- 롤백 트리거: 배포 후 `service-token` 계열 명령 비정상 동작, CLI 기동 실패. +- 주의: 롤백은 *버그 동작으로 복귀*를 의미하므로, 롤백 시 `issue`/`rotate` 사용 보류 권고가 재적용된다. + +## 4. 배포 후 Postmortem + +- (배포 후 작성 — 운영팀) +- 환류 항목 후보: + - 발급 명령 출력에 대한 자동 회귀 부재 → 본 hotfix로 추가됨. 다른 secret-노출 경로(login 토큰 등) 동일 패턴 점검 후속. + - `interface-contract.md` baseline 계약과 구현 간 drift 감지 수단 부재 → 후속 Discovery 후보. + - 이미 회전되어 평문을 잃은 운영 `admin` 토큰(`540e005a…`)은 본 hotfix 배포 후 재회전 필요. + +## Change Log +- 2026-05-22: HOTFIX-001 착수. 운영팀 이슈티켓 접수 → 코드 검증 → Hotfix Exception Path 채택 → 노출 방식 "기본 1회 노출" 확정. +- 2026-05-22: 코드 수정 + 회귀 테스트 3건 반영, 전체 371 passed. Status → verified (배포 대기). 배포는 운영팀이 수행하며 Postmortem은 배포 후 작성. diff --git a/tests/test_cli.py b/tests/test_cli.py index 904cd2e..d8cf9d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1058,6 +1058,58 @@ def test_cli_service_token_issue_supports_json() -> None: assert payload["revoked"] is False assert payload["expires_at"] is not None assert payload["runtime_id"] is not None + # HOTFIX-001 SC-1: JSON 출력의 token 필드는 마스킹되지 않은 평문이어야 한다. + assert "*" not in payload["token"] + stored = repo.get_service_token_by_name("nightly") + assert payload["token"].startswith(stored.token_prefix) + + +def test_render_service_token_result_does_not_mask_token() -> None: + """HOTFIX-001 SC-1/SC-2: render_service_token_result는 token을 평문으로 렌더링한다. + + issue/rotate 모두 동일하게 render_service_token_result를 호출하므로 본 단위 테스트가 + 두 경로(SC-1 issue, SC-2 rotate)의 렌더링 동작을 함께 커버한다. + """ + import io + from rich.console import Console + from app.cli.core.output import render_service_token_result + from app.schemas.auth import ServiceTokenIssueCliResponse + + plaintext = "ySusw9hQ2kPlAiNtExTtOkEnVaLuENotMaskedHere_Wa3w" + response = ServiceTokenIssueCliResponse( + token_id="540e005a-104b-451a-b11c-0eb03968b958", + name="admin", + expires_at=None, + revoked=False, + runtime_id="c544ab21-ce00-41cd-8327-5bf9103ebad8", + daemon_id=None, + token=plaintext, + ) + + for fmt in ("json", "table"): + buffer = io.StringIO() + console = Console(file=buffer, force_terminal=False, width=200) + render_service_token_result(console, fmt, response) + output = buffer.getvalue() + assert plaintext in output, f"{fmt}: 평문 토큰이 출력에 없음" + assert "*" not in output, f"{fmt}: 마스킹 문자가 출력에 포함됨" + + +def test_cli_service_token_issue_table_shows_reveal_warning() -> None: + """HOTFIX-001 SC-3: table 출력은 평문 토큰 + 1회 노출 경고를 표기한다.""" + repo = FakeRepo(_portfolio()) + repo.create_user("alice", permission="read") + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["service-token", "issue", "nightly", "--user", "alice"], + ) + + assert result.exit_code == 0 + stored = repo.get_service_token_by_name("nightly") + assert stored.token_prefix in result.stdout + assert "다시 표시되지 않습니다" in result.stdout def test_cli_service_token_issue_rejects_conflicting_expiry_options() -> None: