diff --git a/CHANGELOG.md b/CHANGELOG.md index db42735b8..997773327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- 분석·유틸리티 도구 2종(`hash_generator`, `email_phone_masker`)을 추가했습니다. 해시 도구는 MD5·SHA-1 호환 fingerprint와 SHA-256을 구분하고, 연락처 도구는 제한된 길이 안에서 이메일 주소와 전화번호를 단순 마스킹합니다. ### Source-bound 요약·업무·관계·일정 경계 - 입력과 무관한 고정 2023 fixture로 결정 사항과 미해결 질문, 업무와 마감일, diff --git a/backend/api/tools.py b/backend/api/tools.py index 8dd28a39b..b1f2e6070 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -576,6 +576,68 @@ async def keyword_extractor_handler(params: Dict[str, Any]) -> Any: ) +async def hash_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: + """Generate compatibility fingerprints plus a SHA-256 security hash.""" + text = params["text"] + if len(text) > ANALYSIS_TEXT_MAX_CHARS: + raise ValueError(f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters") + + encoded = text.encode("utf-8") + return { + "md5": hashlib.md5(encoded, usedforsecurity=False).hexdigest(), # nosec B324 + "sha1": hashlib.sha1(encoded, usedforsecurity=False).hexdigest(), # nosec B324 + "sha256": hashlib.sha256(encoded).hexdigest(), + } + +registry.register( + ToolInfo( + code="hash_generator", + name="지문/해시 생성기 (Fingerprint/Hash Generator)", + description="텍스트의 호환성 지문(MD5, SHA-1) 및 보안 해시(SHA-256) 값을 생성합니다.", + category="유틸리티", + parameters={"text": "string"}, + ), + hash_generator_handler, +) + + +_EMAIL_ATOM = r"A-Za-z0-9!#$%&'*+/=?^_`{|}~" +_EMAIL_PATTERN = re.compile( + rf"(? Dict[str, str]: + """Mask ASCII email and selected Korean or North American phone formats.""" + text = params["text"] + if len(text) > ANALYSIS_TEXT_MAX_CHARS: + raise ValueError(f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters") + + anonymized = _EMAIL_PATTERN.sub("[EMAIL]", text) + anonymized = _PHONE_PATTERN.sub("[PHONE]", anonymized) + + return {"masked_text": anonymized} + +registry.register( + ToolInfo( + code="email_phone_masker", + name="이메일/전화번호 마스킹 (Email/Phone Masker)", + description="텍스트에서 ASCII 이메일 주소와 일부 전화번호 패턴을 단순 마스킹 처리합니다. 보안 목적의 완전한 개인정보 비식별화를 보장하지 않습니다.", + category="유틸리티", + parameters={"text": "string"}, + ), + email_phone_masker_handler, +) + + async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: """Generate one RFC 9562 UUID version 4 for the retained built-in utility.""" return {"uuid": str(uuid.uuid4())} diff --git a/backend/tests/test_contact_masking_privacy_contract.py b/backend/tests/test_contact_masking_privacy_contract.py new file mode 100644 index 000000000..f0db130c3 --- /dev/null +++ b/backend/tests/test_contact_masking_privacy_contract.py @@ -0,0 +1,45 @@ +import pytest + +from api.tools import email_phone_masker_handler + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("source_text", "expected_text"), + [ + ( + "국내 연락처는 010 1234 5678입니다.", + "국내 연락처는 [PHONE]입니다.", + ), + ( + "해외 표기는 +82 10 1234 5678입니다.", + "해외 표기는 [PHONE]입니다.", + ), + ( + "기존 표기는 010-1234-5678입니다.", + "기존 표기는 [PHONE]입니다.", + ), + ( + "북미 연락처는 +1 (415) 555-2671입니다.", + "북미 연락처는 [PHONE]입니다.", + ), + ], +) +async def test_email_phone_masker_masks_common_korean_phone_formats( + source_text: str, + expected_text: str, +) -> None: + """Mask common domestic and +82 Korean phone representations.""" + result = await email_phone_masker_handler({"text": source_text}) + + assert result["masked_text"] == expected_text + + +@pytest.mark.asyncio +async def test_email_phone_masker_rejects_malformed_email_domain() -> None: + """Do not consume malformed addresses while masking valid neighbors.""" + result = await email_phone_masker_handler( + {"text": "Keep a@b..com visible; mask support@example.com."} + ) + + assert result["masked_text"] == "Keep a@b..com visible; mask [EMAIL]." diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 714ac666e..e85e8020b 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -1080,3 +1080,55 @@ def test_execute_analysis_tool_rejects_oversized_text(): f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters" ), } + + + +@pytest.mark.asyncio +async def test_hash_generator_handler(): + from api.tools import hash_generator_handler, ANALYSIS_TEXT_MAX_CHARS + + res = await hash_generator_handler({"text": "hello"}) + assert res["md5"] == "5d41402abc4b2a76b9719d911017c592" + assert res["sha1"] == "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + assert res["sha256"] == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + + with pytest.raises(ValueError, match="Analysis text must not exceed"): + await hash_generator_handler({"text": "x" * (ANALYSIS_TEXT_MAX_CHARS + 1)}) + + + +@pytest.mark.asyncio +async def test_email_phone_masker_handler(): + from api.tools import email_phone_masker_handler, ANALYSIS_TEXT_MAX_CHARS + + res = await email_phone_masker_handler({"text": "Contact me at user@example.com or 010-1234-5678."}) + assert res["masked_text"] == "Contact me at [EMAIL] or [PHONE]." + + with pytest.raises(ValueError, match="Analysis text must not exceed"): + await email_phone_masker_handler({"text": "x" * (ANALYSIS_TEXT_MAX_CHARS + 1)}) + + +def test_execute_hash_generator(): + with TestClient(app) as client: + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "hello"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["md5"] == "5d41402abc4b2a76b9719d911017c592" + assert data["result"]["sha256"] == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + +def test_execute_email_phone_masker(): + with TestClient(app) as client: + response = client.post( + "/api/tools/email_phone_masker/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "My email is test@example.com and phone is 010-1234-5678, but 1234 is not."}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["masked_text"] == "My email is [EMAIL] and phone is [PHONE], but 1234 is not."