Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
## [Unreleased]
### 이메일 보안 판정 경계 (Email Security Verdict Boundary)

- 고정 키워드와 발신자 도메인 suffix만으로 확정적 spam/phishing boolean과
risk score를 반환하던 `spam_phishing_detector`를 내장 도구 레지스트리에서
제거했습니다. 이를 대신하는 keyword·suffix fallback은 없으며, source-bound
evidence, provider verdict, provenance가 없는 경우에는 보안 판정을 생성하지 않고
fail closed/unknown으로 처리해야 합니다. 아래의 과거 기능 추가 기록은 당시 변경
이력으로 보존하며 현재 지원 계약을 뜻하지 않습니다.

### 도구 변경 경계 (Tool Mutation Boundary)

- 프로세스 전역·비영속 레지스트리를 모든 인증 사용자가 변경할 수 있었던
Expand Down
48 changes: 0 additions & 48 deletions backend/api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,43 +257,6 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any:
}


async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
"""Score an email body for simple spam and phishing risk indicators."""
email_content = params.get("email_content", "")
sender_domain = params.get("sender_domain", "")
normalized_content = email_content.lower()
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)
spam_hits = sorted(term for term in spam_terms if term in normalized_content)
suspicious_domain = (
normalized_domain.endswith((".ru", ".zip", ".tk"))
or "login" in normalized_domain
or "secure-" in normalized_domain
)
risk_score = min(
100,
10
+ (20 * len(phishing_hits))
+ (15 * len(spam_hits))
+ (35 if suspicious_domain else 0),
)
warnings: list[str] = []
if phishing_hits:
warnings.append(f"phishing keywords detected: {', '.join(phishing_hits)}")
if spam_hits:
warnings.append(f"spam urgency keywords detected: {', '.join(spam_hits)}")
if suspicious_domain:
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)),
"risk_score": risk_score,
"warnings": warnings,
}


async def reply_drafter_handler(params: Dict[str, Any]) -> Any:
"""Draft a formal reply using the operator's requested intent."""
original_email = params.get("original_email", "").strip()
Expand Down Expand Up @@ -583,17 +546,6 @@ async def base64_decoder_handler(params: Dict[str, Any]) -> Dict[str, str]:
email_translator_handler,
)

registry.register(
ToolInfo(
code="spam_phishing_detector",
name="스팸 및 피싱 탐지기 (Spam & Phishing Detector)",
description="이메일 본문과 발신자 도메인을 분석하여 스팸 및 피싱 위험도를 평가합니다.",
category="보안",
parameters={"email_content": "string", "sender_domain": "string"},
),
spam_phishing_detector_handler,
)

registry.register(
ToolInfo(
code="reply_drafter",
Expand Down
76 changes: 41 additions & 35 deletions backend/tests/test_tools_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,47 @@ def test_get_tool_not_found():
assert response.json() == {"detail": "Tool not found"}


def test_startup_catalog_omits_unsupported_spam_phishing_detector():
with TestClient(app) as client:
response = client.get(
"/api/tools",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
)

assert response.status_code == 200
assert "spam_phishing_detector" not in {
tool["code"] for tool in response.json()
}


def test_removed_spam_phishing_detector_detail_returns_not_found():
with TestClient(app) as client:
response = client.get(
"/api/tools/spam_phishing_detector",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
)

assert response.status_code == 404
assert response.json() == {"detail": "Tool not found"}


def test_removed_spam_phishing_detector_execute_returns_not_found():
with TestClient(app) as client:
response = client.post(
"/api/tools/spam_phishing_detector/execute",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
json={
"parameters": {
"email_content": "Urgent: update your bank password now",
"sender_domain": "secure-bank-login.ru",
}
},
)

assert response.status_code == 404
assert response.json() == {"detail": "Tool not found"}


@pytest.mark.parametrize(
"tool_code", ["email_categorizer", "meeting_agenda_generator"]
)
Expand Down Expand Up @@ -894,27 +935,6 @@ def test_execute_email_translator():
assert data["result"]["source_language_detected"] == "en"


def test_execute_spam_phishing_detector():
with TestClient(app) as client:
response = client.post(
"/api/tools/spam_phishing_detector/execute",
headers={"Authorization": f"Bearer {_signed_session_token()}"},
json={
"parameters": {
"email_content": "Urgent: update your bank password now",
"sender_domain": "secure-bank-login.ru",
}
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["result"]["is_phishing"] is True
assert data["result"]["is_spam"] is True
assert data["result"]["risk_score"] >= 90
assert any("sender domain" in warning for warning in data["result"]["warnings"])


def test_execute_reply_drafter():
with TestClient(app) as client:
response = client.post(
Expand Down Expand Up @@ -1012,7 +1032,6 @@ async def test_analysis_handlers_safe_and_fallthrough_paths():
email_translator_handler,
grammar_checker_handler,
sentiment_analyzer_handler,
spam_phishing_detector_handler,
)

untranslated = await email_translator_handler(
Expand All @@ -1021,19 +1040,6 @@ async def test_analysis_handlers_safe_and_fallthrough_paths():
assert untranslated["translated_text"] == "Hello, thank you for the meeting."
assert untranslated["source_language_detected"] == "en"

safe_email = await spam_phishing_detector_handler(
{
"email_content": "Here are the approved meeting notes.",
"sender_domain": "example.com",
}
)
assert safe_email == {
"is_spam": False,
"is_phishing": False,
"risk_score": 10,
"warnings": [],
}

nonurgent_negative = await sentiment_analyzer_handler(
{"text": "I am disappointed."}
)
Expand Down