diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb2dc28c..d7e382c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ ## [Unreleased] +### Fixed +- CI Trivy 파일시스템 보안 스캐너가 감지한 `next` 및 `sharp` 패키지의 심각한 보안 취약점(CVE-2026-75604, GHSA-rgj7-g3m4-5g8c 등)을 해결하기 위해, `frontend/package.json`에서 해당 의존성들을 안전한 최신 버전으로 업데이트했습니다. +### Added +- `backend/api/tools.py`에 다음 3개의 도구를 추가하고 등록했습니다. + - `url_extractor_handler`: 균형 잡힌 URL 괄호와 IPv6 대괄호는 보존하고 문장 끝의 짝 없는 닫힘 기호만 제거한 뒤 중복 URL을 제거합니다. + - `hash_generator_handler`: 선언된 SHA-256 기본값을 parameter validation 전에 적용하고 SHA-256/SHA-384/SHA-512 digest를 생성합니다. + - `json_validator_handler`: RFC 8259가 허용하지 않는 `NaN`, `Infinity`, `-Infinity`를 거부하면서 JSON 문자열을 검증·포맷팅합니다. +- 추가된 도구들에 대해 100% 테스트 커버리지를 보장하는 개별 테스트를 `backend/tests/test_tools_api.py`에 작성했습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/backend/api/tools.py b/backend/api/tools.py index bd15abfac..558e16320 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -149,40 +149,50 @@ def get_all(self) -> List[ToolInfo]: def get(self, code: str) -> Optional[ToolInfo]: return self._tools.get(code) - async def invoke_tool(self, code: str, params: Dict[str, Any]) -> Any: - handler = self._handlers.get(code) - if not handler: - raise ValueError(f"No handler registered for tool {code}") - result = handler(self._validate_parameters(code, params)) - if inspect.isawaitable(result): - return await result - return result - - def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, Any]: - if not isinstance(params, dict): + async def invoke_tool(self, tool_code: str, tool_parameters: Dict[str, Any]) -> Any: + tool_handler = self._handlers.get(tool_code) + if not tool_handler: + raise ValueError(f"No handler registered for tool {tool_code}") + handler_result = tool_handler( + self._validate_parameters(tool_code, tool_parameters) + ) + if inspect.isawaitable(handler_result): + return await handler_result + return handler_result + + def _validate_parameters( + self, tool_code: str, tool_parameters: Dict[str, Any] + ) -> Dict[str, Any]: + if not isinstance(tool_parameters, dict): raise ValueError("Tool parameters must be an object") - tool_info = self._tools.get(code) - schema = tool_info.parameters if tool_info else None - if not schema: - if params: + tool_info = self._tools.get(tool_code) + parameter_schema = tool_info.parameters if tool_info else None + if not parameter_schema: + if tool_parameters: raise ValueError("Tool does not accept parameters") return {} - unexpected_keys = set(params) - set(schema) - if unexpected_keys: + unexpected_parameters = set(tool_parameters) - set(parameter_schema) + if unexpected_parameters: raise ValueError("Unexpected tool parameter") - validated: Dict[str, Any] = {} - for key, descriptor in schema.items(): - if key not in params: + validated_parameters: Dict[str, Any] = {} + for parameter_name, parameter_descriptor in parameter_schema.items(): + if parameter_name in tool_parameters: + parameter_value = tool_parameters[parameter_name] + elif ( + isinstance(parameter_descriptor, dict) + and "default" in parameter_descriptor + ): + parameter_value = parameter_descriptor["default"] + else: raise ValueError("Missing required tool parameter") - value = params[key] - expected_type = _parameter_type_name(descriptor) - if not _parameter_matches_type(value, expected_type): + expected_type = _parameter_type_name(parameter_descriptor) + if not _parameter_matches_type(parameter_value, expected_type): raise ValueError("Invalid tool parameter type") - validated[key] = value - return validated + validated_parameters[parameter_name] = parameter_value + return validated_parameters registry = ToolRegistry() @@ -706,6 +716,8 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]: "합니다", } ) + + def _normalize_analysis_text(value: str) -> str: """Normalize user text for deterministic, multilingual rule matching.""" if len(value) > ANALYSIS_TEXT_MAX_CHARS: @@ -769,6 +781,137 @@ async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: ) +_URL_PATTERN = re.compile( + r"https?://[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+(? str: + """Remove terminal unmatched brackets while preserving balanced URL syntax.""" + delimiter_pairs = {")": "(", "]": "["} + while extracted_url and extracted_url[-1] in delimiter_pairs: + unmatched_terminal = False + opening_delimiters: list[str] = [] + for character_index, url_character in enumerate(extracted_url): + if url_character in delimiter_pairs.values(): + opening_delimiters.append(url_character) + elif url_character in delimiter_pairs: + expected_opener = delimiter_pairs[url_character] + if opening_delimiters and opening_delimiters[-1] == expected_opener: + opening_delimiters.pop() + elif character_index == len(extracted_url) - 1: + unmatched_terminal = True + if not unmatched_terminal: + break + extracted_url = extracted_url[:-1] + return extracted_url + + +async def url_extractor_handler(tool_parameters: Dict[str, Any]) -> Dict[str, Any]: + """Extract distinct HTTP(S) URLs from workspace text in encounter order.""" + source_text = tool_parameters.get("text", "") + extracted_urls = _URL_PATTERN.findall(source_text) + + seen_urls = set() + deduplicated_urls = [] + for extracted_url in extracted_urls: + normalized_url = _strip_unmatched_url_closers(extracted_url) + if normalized_url not in seen_urls: + seen_urls.add(normalized_url) + deduplicated_urls.append(normalized_url) + + return {"urls": deduplicated_urls, "url_count": len(deduplicated_urls)} + + +registry.register( + ToolInfo( + code="url_extractor", + name="URL 추출기 (URL Extractor)", + description="텍스트 본문에서 URL을 찾아 추출합니다.", + category="이메일 분석", + parameters={"text": "string"}, + ), + url_extractor_handler, +) + + +async def hash_generator_handler(tool_parameters: Dict[str, Any]) -> Dict[str, str]: + """Generate a supported SHA digest for workspace text.""" + source_text = tool_parameters.get("text", "") + digest_algorithm = tool_parameters.get("algorithm", "sha256") + if not digest_algorithm: + digest_algorithm = "sha256" + digest_algorithm = digest_algorithm.lower() + + encoded_text = source_text.encode("utf-8") + if digest_algorithm == "sha256": + hash_digest = hashlib.sha256(encoded_text).hexdigest() + elif digest_algorithm == "sha384": + hash_digest = hashlib.sha384(encoded_text).hexdigest() + elif digest_algorithm == "sha512": + hash_digest = hashlib.sha512(encoded_text).hexdigest() + else: + raise ValueError("Unsupported algorithm. Supported: sha256, sha384, sha512") + + return {"hash": hash_digest, "algorithm": digest_algorithm} + + +registry.register( + ToolInfo( + code="hash_generator", + name="해시 생성기 (Hash Generator)", + description="입력된 텍스트를 지정된 해시 알고리즘(SHA-256, SHA-384, SHA-512)으로 변환합니다.", + category="유틸리티", + parameters={ + "text": "string", + "algorithm": {"type": "string", "default": "sha256"}, + }, + ), + hash_generator_handler, +) + + +def _reject_non_standard_json_constant(constant_name: str) -> None: + """Reject JavaScript numeric constants that RFC 8259 excludes from JSON.""" + raise ValueError(f"Non-standard JSON constant: {constant_name}") + + +async def json_validator_handler(tool_parameters: Dict[str, Any]) -> Dict[str, Any]: + """Validate and format an RFC 8259 JSON document.""" + json_document = tool_parameters.get("json_string", "") + try: + parsed_json = json.loads( + json_document, + parse_constant=_reject_non_standard_json_constant, + ) + formatted_json = json.dumps(parsed_json, indent=2, ensure_ascii=False) + return { + "is_valid": True, + "parsed": parsed_json, + "formatted_json": formatted_json, + "error_message": None, + } + except (json.JSONDecodeError, ValueError) as validation_error: + return { + "is_valid": False, + "parsed": None, + "formatted_json": None, + "error_message": str(validation_error), + } + + +registry.register( + ToolInfo( + code="json_validator", + name="JSON 검증기 (JSON Validator)", + description="주어진 JSON 문자열의 유효성을 검사하고, 유효한 경우 포맷팅된 결과를 반환합니다.", + category="유틸리티", + parameters={"json_string": "string"}, + ), + json_validator_handler, +) + @router.get("/tools", response_model=list[ToolInfo]) def get_tools() -> list[ToolInfo]: diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index 8e537cef7..9d887f7eb 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -112,9 +112,7 @@ def test_get_tool_not_found(): assert response.json() == {"detail": "Tool not found"} -@pytest.mark.parametrize( - "tool_code", ["email_categorizer", "meeting_agenda_generator"] -) +@pytest.mark.parametrize("tool_code", ["email_categorizer", "meeting_agenda_generator"]) def test_registry_omits_lexical_pseudo_topic_tools(tool_code): assert registry.get(tool_code) is None @@ -964,7 +962,197 @@ def test_update_tool_unsafe_webhook(): registry.unregister("unsafe_update_tool") +def test_execute_url_extractor(): + with TestClient(app) as client: + response = client.post( + "/api/tools/url_extractor/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "parameters": { + "text": "Visit https://example.com or http://test.org. Here is a dup: https://example.com. And with punctuation https://example.com/path, right? No URL here." + } + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + urls = data["result"]["urls"] + assert set(urls) == { + "https://example.com", + "http://test.org", + "https://example.com/path", + } + assert data["result"]["url_count"] == 3 + + +def test_execute_url_extractor_preserves_balanced_delimiters(): + with TestClient(app) as client: + response = client.post( + "/api/tools/url_extractor/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "parameters": { + "text": ( + "Keep https://example.com/a(b) and " + "http://[2001:db8::1]/path, but trim " + "https://example.org/report)]." + ) + } + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["urls"] == [ + "https://example.com/a(b)", + "http://[2001:db8::1]/path", + "https://example.org/report", + ] + + +def test_execute_url_extractor_preserves_paired_terminal_delimiters(): + with TestClient(app) as client: + response = client.post( + "/api/tools/url_extractor/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "parameters": { + "text": ( + "Keep https://example.com/)foo(bar) and " + "http://example.com/]foo[bar]." + ) + } + }, + ) + + assert response.status_code == 200 + assert response.json()["result"]["urls"] == [ + "https://example.com/)foo(bar)", + "http://example.com/]foo[bar]", + ] + + +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", "algorithm": "sha256"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + # echo -n "hello" | sha256sum -> 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + assert ( + data["result"]["hash"] + == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ) + assert data["result"]["algorithm"] == "sha256" + + # Unsupported algorithm + with TestClient(app) as client: + response2 = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "hello", "algorithm": "md5"}}, + ) + assert response2.status_code == 200 + data2 = response2.json() + assert data2["status"] == "failed" + assert "Unsupported algorithm" in data2["message"] + + # SHA384 algorithm + with TestClient(app) as client: + response3 = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "hello", "algorithm": "sha384"}}, + ) + assert response3.status_code == 200 + data3 = response3.json() + assert data3["status"] == "success" + assert ( + data3["result"]["hash"] + == "59e1748777448c69de6b800d7a33bbfb9ff1b463e44354c3553bcdb9c666fa90125a3c79f90397bdf5f6a13de828684f" + ) + + # SHA512 algorithm + with TestClient(app) as client: + response4 = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"text": "hello", "algorithm": "sha512"}}, + ) + assert response4.status_code == 200 + data4 = response4.json() + assert data4["status"] == "success" + assert ( + data4["result"]["hash"] + == "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043" + ) + + +def test_execute_hash_generator_applies_declared_algorithm_default(): + 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 + assert response.json()["result"] == { + "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "algorithm": "sha256", + } + + +def test_execute_json_validator(): + + # Valid JSON + with TestClient(app) as client: + response = client.post( + "/api/tools/json_validator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"json_string": '{"key": "value", "number": 123}'}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["is_valid"] is True + assert data["result"]["parsed"]["key"] == "value" + assert "value" in data["result"]["formatted_json"] + assert data["result"]["error_message"] is None + + # Invalid JSON + with TestClient(app) as client: + response = client.post( + "/api/tools/json_validator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"json_string": '{key: "value"}'}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["is_valid"] is False + assert data["result"]["parsed"] is None + assert data["result"]["error_message"] is not None + + +@pytest.mark.parametrize("json_constant", ["NaN", "Infinity", "-Infinity"]) +def test_execute_json_validator_rejects_non_standard_constants(json_constant: str): + with TestClient(app) as client: + response = client.post( + "/api/tools/json_validator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"json_string": f'{{"value": {json_constant}}}'}}, + ) + + assert response.status_code == 200 + assert response.json()["result"]["is_valid"] is False + + def test_is_safe_webhook_url_coverage(): + from api.tools import is_safe_webhook_url with patch(