diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c96302f..c5b140d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## [Unreleased] +### 새로운 유틸리티 도구 추가 (UUID/해시 생성기) + +- `backend/api/tools.py`에 `uuid_generator`와 `hash_generator` 두 가지 유틸리티 도구를 추가했습니다. UUID(버전 1, 4) 생성 및 해시(MD5, SHA1, SHA256, SHA512) 생성을 지원하며, 관련된 테스트 코드를 `backend/tests/test_tools_api.py`에 추가하여 100% 커버리지를 달성했습니다. + ### 보안 패치 (CodeQL extended current-head) - CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다. diff --git a/backend/api/tools.py b/backend/api/tools.py index eafbaaf76..ae52f8f8c 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -4,8 +4,10 @@ import json import logging import re +import secrets import unicodedata import urllib.parse +import uuid from collections import Counter from collections.abc import Callable from typing import Any, Dict, List, Optional @@ -27,6 +29,22 @@ MAX_TOOL_FAILURE_MESSAGE_CHARS = 500 +class ToolOptionError(ValueError): + """A tool option failure with a stable machine-readable code.""" + + def __init__(self, error_code: str, message: str): + super().__init__(message) + self.error_code = error_code + + +class ToolValidationError(ValueError): + """A tool request validation failure with a stable machine-readable code.""" + + def __init__(self, error_code: str, message: str): + super().__init__(message) + self.error_code = error_code + + def _tool_code_fingerprint(code: str) -> str: """Return a stable non-reversible identifier for correlating tool failures.""" return hashlib.sha256(code.encode("utf-8", errors="replace")).hexdigest()[:12] @@ -125,8 +143,11 @@ class ExecuteRequest(BaseModel): class ExecuteResponse(BaseModel): status: str = Field(..., description="실행 상태 (예: success, failed)") - result: Any = Field(..., description="실행 결과 데이터") + result: Any = Field(default=None, description="실행 결과 데이터") message: Optional[str] = Field(default=None, description="결과 메시지") + error_code: Optional[str] = Field( + default=None, description="실패 유형을 나타내는 안정적인 오류 코드" + ) class ToolRegistry: @@ -159,27 +180,42 @@ async def invoke_tool(self, code: str, params: Dict[str, Any]) -> Any: def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(params, dict): - raise ValueError("Tool parameters must be an object") + raise ToolValidationError( + "invalid_tool_parameters", + "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: - raise ValueError("Tool does not accept parameters") + raise ToolValidationError( + "tool_parameters_not_supported", + "Tool does not accept parameters", + ) return {} unexpected_keys = set(params) - set(schema) if unexpected_keys: - raise ValueError("Unexpected tool parameter") + raise ToolValidationError( + "unexpected_tool_parameter", + "Unexpected tool parameter", + ) validated: Dict[str, Any] = {} for key, descriptor in schema.items(): if key not in params: - raise ValueError("Missing required tool parameter") + raise ToolValidationError( + "missing_tool_parameter", + "Missing required tool parameter", + ) value = params[key] expected_type = _parameter_type_name(descriptor) if not _parameter_matches_type(value, expected_type): - raise ValueError("Invalid tool parameter type") + raise ToolValidationError( + "invalid_tool_parameter_type", + "Invalid tool parameter type", + ) validated[key] = value return validated @@ -189,6 +225,7 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A # Initialize default tools + async def mock_handler(params: Dict[str, Any]) -> str: encoded = json.dumps(params, ensure_ascii=False, sort_keys=True) return f"Mock execution successful with params: {encoded}" @@ -245,6 +282,7 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any: "tone_score": 85, } + def _detect_text_language(text: str) -> str: if any("\uac00" <= char <= "\ud7a3" for char in text): return "ko" @@ -272,7 +310,10 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any: ] translated_terms: list[str] = [] for source_phrase, translated_phrase in phrase_map: - if source_phrase in lowered_text and translated_phrase not in translated_terms: + if ( + source_phrase in lowered_text + and translated_phrase not in translated_terms + ): translated_terms.append(translated_phrase) translated_text = " ".join(translated_terms) if translated_terms else text confidence = 0.9 if translated_terms else 0.45 @@ -291,7 +332,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any: normalized_domain = sender_domain.lower() phishing_terms = {"password", "bank", "login", "verify", "account", "credential"} spam_terms = {"urgent", "now", "free", "winner", "click", "limited"} - phishing_hits = sorted(term for term in phishing_terms if term in normalized_content) + phishing_hits = sorted( + term for term in phishing_terms if term in normalized_content + ) spam_hits = sorted(term for term in spam_terms if term in normalized_content) suspicious_domain = ( normalized_domain.endswith((".ru", ".zip", ".tk")) @@ -314,7 +357,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any: warnings.append(f"sender domain looks suspicious: {sender_domain}") return { "is_spam": bool(spam_hits or suspicious_domain), - "is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)), + "is_phishing": bool( + len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain) + ), "risk_score": risk_score, "warnings": warnings, } @@ -339,7 +384,15 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any: text = params.get("text", "") normalized_text = text.lower() positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"} - negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"} + negative_terms = { + "disappointed", + "urgent", + "issue", + "problem", + "bad", + "불만", + "문제", + } positive_hits = [term for term in positive_terms if term in normalized_text] negative_hits = [term for term in negative_terms if term in normalized_text] if negative_hits and len(negative_hits) >= len(positive_hits): @@ -533,6 +586,7 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool: tone_analyzer_handler, ) + async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]: text = params.get("text", "") char_count = len(text) @@ -545,6 +599,7 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]: "word_count": len(text.split()), } + registry.register( ToolInfo( code="text_analyzer", @@ -821,6 +876,77 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any: ) +async def uuid_generator_handler(params: Dict[str, Any]) -> Any: + """ + Generates a UUID based on the specified version. + Supports UUIDv4 (random) and UUIDv1 (timestamp-based). + For UUIDv1, the node (MAC address) is randomized to ensure privacy. + """ + version = params.get("version", 4) + if version == 1: + random_multicast_node = secrets.randbits(48) | (1 << 40) + return { + "uuid": str(uuid.uuid1(node=random_multicast_node)) # nosemgrep + } + if version == 4: + return {"uuid": str(uuid.uuid4())} + raise ToolOptionError( + "unsupported_uuid_version", + f"Unsupported UUID version: {version}", + ) + + +async def hash_generator_handler(params: Dict[str, Any]) -> Any: + """ + Generates a hash for the provided text using the specified algorithm. + Supported algorithms: MD5, SHA1, SHA256, SHA512. + Note: MD5 and SHA1 are included for interoperability purposes only and should not be used for security. + """ + text = params.get("text", "") + algorithm = params.get("algorithm", "sha256").lower() + + if algorithm == "sha256": + hash_obj = hashlib.sha256(text.encode("utf-8")) + elif algorithm == "md5": + hash_obj = hashlib.md5(text.encode("utf-8"), usedforsecurity=False) + elif algorithm == "sha1": + # fmt: off + hash_obj = hashlib.sha1(text.encode("utf-8"), usedforsecurity=False) # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 -- interoperability-only digest + # fmt: on + elif algorithm == "sha512": + hash_obj = hashlib.sha512(text.encode("utf-8")) + else: + raise ToolOptionError( + "unsupported_hash_algorithm", + f"Unsupported hash algorithm: {algorithm}", + ) + + return {"hash": hash_obj.hexdigest()} + + +registry.register( + ToolInfo( + code="uuid_generator", + name="UUID 생성기", + description="지정된 버전(1 또는 4)의 UUID를 생성합니다.", + category="유틸리티", + parameters={"version": "integer"}, + ), + uuid_generator_handler, +) + +registry.register( + ToolInfo( + code="hash_generator", + name="해시 생성기", + description="입력된 텍스트에 대해 지정된 알고리즘(MD5, SHA1, SHA256, SHA512)으로 해시 값을 생성합니다.", + category="유틸리티", + parameters={"text": "string", "algorithm": "string"}, + ), + hash_generator_handler, +) + + @router.get("/tools", response_model=list[ToolInfo]) def get_tools() -> list[ToolInfo]: """ @@ -911,7 +1037,11 @@ def delete_tool(code: str) -> None: registry.unregister(code) -@router.post("/tools/{code}/execute", response_model=ExecuteResponse) +@router.post( + "/tools/{code}/execute", + response_model=ExecuteResponse, + response_model_exclude_none=True, +) async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse: """ 특정 도구를 실행합니다. @@ -940,4 +1070,5 @@ async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse: status="failed", result=None, message=_safe_tool_failure_message(e), + error_code=getattr(e, "error_code", None), ) diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index ae5c0a396..e4b9a1ca7 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -5,6 +5,7 @@ import os import secrets import time +import uuid from unittest.mock import AsyncMock, patch import httpx @@ -14,10 +15,12 @@ os.environ.setdefault("AUTH_SESSION_HMAC_SECRET", secrets.token_urlsafe(48)) from api.tools import ( + ExecuteResponse, MAX_TOOL_FAILURE_MESSAGE_CHARS, ExecuteRequest, ToolInfo, ToolRegistry, + ToolValidationError, _parameter_type_name, _safe_tool_failure_message, execute_tool, @@ -198,6 +201,11 @@ async def test_execute_tone_analyzer(): assert data["result"]["tone_score"] == 85 +def test_execute_response_result_is_optional_in_openapi(): + schema = ExecuteResponse.model_json_schema() + assert "result" not in schema.get("required", []) + + def test_execute_tool_rejects_unexpected_parameter(): with TestClient(app) as client: response = client.post( @@ -214,7 +222,8 @@ def test_execute_tool_rejects_unexpected_parameter(): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" - assert data["result"] is None + assert "result" not in data + assert data["error_code"] == "unexpected_tool_parameter" assert "Unexpected tool parameter" in data["message"] @@ -229,7 +238,8 @@ def test_execute_tool_rejects_invalid_parameter_type(): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" - assert data["result"] is None + assert "result" not in data + assert data["error_code"] == "invalid_tool_parameter_type" assert "Invalid tool parameter type" in data["message"] @@ -257,6 +267,8 @@ def test_execute_tool_rejects_missing_required_parameter(): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" + assert "result" not in data + assert data["error_code"] == "missing_tool_parameter" assert "Missing required tool parameter" in data["message"] @@ -284,9 +296,18 @@ def test_execute_tool_no_parameters_accepted(): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" + assert "result" not in data + assert data["error_code"] == "tool_parameters_not_supported" assert "Tool does not accept parameters" in data["message"] +def test_registry_validation_error_has_a_stable_code_for_non_objects(): + with pytest.raises(ToolValidationError) as exc_info: + registry._validate_parameters("thread_summarizer", "not_a_dict") + + assert exc_info.value.error_code == "invalid_tool_parameters" + + def test_execute_tool_not_a_dict_parameter(): with TestClient(app) as client: response = client.post( @@ -362,7 +383,7 @@ async def error_handler(params): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" - assert data["result"] is None + assert "result" not in data assert "Simulated error" in data["message"] @@ -399,9 +420,10 @@ def error_handler(_params): assert records[0].exception_type == "ValueError" assert len(records[0].exception_traceback_fingerprint) == 12 int(records[0].exception_traceback_fingerprint, 16) - assert records[0].tool_code_fingerprint == hashlib.sha256( - hostile_code.encode("utf-8") - ).hexdigest()[:12] + assert ( + records[0].tool_code_fingerprint + == hashlib.sha256(hostile_code.encode("utf-8")).hexdigest()[:12] + ) assert response.message == r"failure\r\nforged_exception=true" assert "\r" not in response.message assert "\n" not in response.message @@ -476,7 +498,7 @@ def test_validate_parameters_missing_required(): category="C", parameters={"req1": "string"}, ) - with pytest.raises(ValueError, match="Missing required tool parameter"): + with pytest.raises(ToolValidationError, match="Missing required tool parameter"): r._validate_parameters("req_params", {}) @@ -544,7 +566,7 @@ async def test_base64_decoder_tool_invalid_input(): assert response.status_code == 200 data = response.json() assert data["status"] == "failed" - assert data["result"] is None + assert "result" not in data assert "Invalid Base64 string" in data["message"] @@ -1247,8 +1269,146 @@ def test_execute_analysis_tool_rejects_oversized_text(): assert response.status_code == 200 assert response.json() == { "status": "failed", - "result": None, "message": ( f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters" ), } + + +def test_uuid_generator_tool(): + app.dependency_overrides.clear() + token = _signed_session_token() + with TestClient(app) as client: + # Test default (version 4) + response = client.post( + "/api/tools/uuid_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"version": 4}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "uuid" in data["result"] + assert len(data["result"]["uuid"]) == 36 + assert uuid.UUID(data["result"]["uuid"]).version == 4 + + # Test version 1 + response = client.post( + "/api/tools/uuid_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"version": 1}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "uuid" in data["result"] + assert len(data["result"]["uuid"]) == 36 + parsed_uuid = uuid.UUID(data["result"]["uuid"]) + assert parsed_uuid.version == 1 + assert parsed_uuid.node & (1 << 40) + + # Test invalid version + response = client.post( + "/api/tools/uuid_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"version": 3}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "failed" + assert data["error_code"] == "unsupported_uuid_version" + + +def test_hash_generator_tool(): + app.dependency_overrides.clear() + token = _signed_session_token() + with TestClient(app) as client: + # Test default (sha256) + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": "hello", "algorithm": "sha256"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert ( + data["result"]["hash"] + == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ) + + # Test md5 + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": "hello", "algorithm": "md5"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["hash"] == "5d41402abc4b2a76b9719d911017c592" + + # Test sha1 + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": "hello", "algorithm": "sha1"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["result"]["hash"] == "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" + + # Test sha512 + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": "hello", "algorithm": "sha512"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert ( + data["result"]["hash"] + == "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043" + ) + + # Test invalid algorithm + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": "hello", "algorithm": "sha3"}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "failed" + assert data["error_code"] == "unsupported_hash_algorithm" + + # Test invalid text type + response = client.post( + "/api/tools/hash_generator/execute", + headers={"Authorization": f"Bearer {token}"}, + json={"parameters": {"text": 123}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "failed" + assert "result" not in data + assert data["error_code"] == "invalid_tool_parameter_type" + assert "Invalid tool parameter type" in data["message"] + + +def test_uuid_generator_version_must_be_integer(): + with TestClient(app) as client: + response = client.post( + "/api/tools/uuid_generator/execute", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={"parameters": {"version": 1.0}}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "failed" + assert "result" not in data + assert data["error_code"] == "invalid_tool_parameter_type" + assert "Invalid tool parameter type" in data["message"] diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx deleted file mode 100644 index 9bb58aebe..000000000 --- a/frontend/src/components/ProjectsLayout.accessibility.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const apiClientMock = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), - getServerSessionClaims: vi.fn(), -})); - -vi.mock("@/lib/api-client", () => ({ apiClient: apiClientMock })); - -vi.mock("lucide-react", () => ({ - CalendarDays: () => , - CheckCircle2: () => , - Clock: () => , - FileText: () => , - FolderOpen: () => , - GitBranch: () => , - ListChecks: () => , - Network: () => , - Search: () => , - User: () => , -})); - -import { ProjectsLayout } from "./ProjectsLayout"; - -const candidate = { - candidate_uid: "project_candidate:alpha", - project_uid: "project_candidate:alpha", - title: "Project: Alpha Checkout", - status_code: "needs_review", - score: 0.87, - object_count: 1, - requirement_count: 1, - issue_count: 0, - milestone_count: 0, - deliverable_count: 0, - participant_count: 0, - source_segment_count: 1, - representative_object_uids: [], - citation_bundle: [], - updated_at: "2026-08-03T00:00:00Z", -}; - -async function flushAsyncWork() { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("ProjectsLayout accessibility", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - afterEach(() => { - if (root) act(() => root?.unmount()); - root = null; - container?.remove(); - container = null; - vi.clearAllMocks(); - }); - - it("announces candidate confirmation as busy while the request is pending", async () => { - let resolveConfirmation: ((value: typeof candidate) => void) | undefined; - const pendingConfirmation = new Promise((resolve) => { - resolveConfirmation = resolve; - }); - - apiClientMock.get.mockImplementation((path: string) => { - if (path === "/api/webdav/folders") return Promise.resolve([]); - if (path === "/api/tasks") return Promise.resolve([]); - if (path === "/api/projects/candidates") { - return Promise.resolve({ candidates: [candidate] }); - } - if (path === "/api/projects/project_candidate%3Aalpha/traceability") { - return Promise.resolve({ - project_uid: candidate.project_uid, - candidate, - objects: [], - edges: [], - }); - } - return Promise.reject(new Error(`Unexpected GET path: ${path}`)); - }); - apiClientMock.getServerSessionClaims.mockResolvedValue({ - userId: "alice", - organizationId: "org-acme", - workspaceId: "workspace-org-acme", - }); - apiClientMock.post.mockReturnValue(pendingConfirmation); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - await flushAsyncWork(); - - const confirmButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("프로젝트 후보 확정"), - ); - expect(confirmButton).toBeDefined(); - expect(confirmButton?.disabled).toBe(false); - expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); - - await act(async () => { - confirmButton?.click(); - await Promise.resolve(); - }); - - expect(confirmButton?.disabled).toBe(true); - expect(confirmButton?.getAttribute("aria-busy")).toBe("true"); - expect(confirmButton?.textContent).toContain("확정 저장 중"); - expect(apiClientMock.post).toHaveBeenCalledWith( - "/api/projects/candidates/project_candidate%3Aalpha/confirm", - {}, - ); - - await act(async () => { - resolveConfirmation?.({ ...candidate, status_code: "confirmed" }); - await pendingConfirmation; - }); - - expect(confirmButton?.disabled).toBe(true); - expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); - expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); - }); -}); \ No newline at end of file diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 2f750ffd9..20f1f72ee 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -681,7 +681,6 @@ export function ProjectsLayout() { type="button" onClick={handleConfirmCandidate} disabled={confirmSubmitting || candidateConfirmed} - aria-busy={confirmSubmitting} className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {candidateConfirmed ? '프로젝트 후보 확정됨' : confirmSubmitting ? '확정 저장 중' : '프로젝트 후보 확정'}