Skip to content
Merged
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
18 changes: 9 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
MOCK_LLM_URL=http://localhost:8001/v1/chat/completions
ADMIN_API_TOKEN=change-me-admin-token
UPSTREAM_LLM_PROVIDER=mock
AUTH_DB_PATH=backend/data/auth.sqlite3
UI_ALLOWED_ORIGINS=http://127.0.0.1:5500,http://localhost:5500
LLM_PROVIDER=mock
UPSTREAM_TIMEOUT_SECONDS=10
UPSTREAM_RETRY_COUNT=1
OPENAI_CHAT_URL=https://api.openai.com/v1/chat/completions
OPENAI_MODEL=gpt-4o-mini
AZURE_OPENAI_CHAT_URL=
AZURE_OPENAI_API_VERSION=2024-02-15-preview
AZURE_OPENAI_DEPLOYMENT=
OLLAMA_CHAT_URL=http://localhost:11434/api/chat
OLLAMA_MODEL=llama3
OPENAI_API_KEY=
OPENAI_MODEL=
OPENAI_TIMEOUT_SECONDS=30
OPENAI_MAX_OUTPUT_TOKENS=1000
ENABLE_MODEL_DETECTOR=true
DETECTION_MODE=hybrid
MODEL_DETECTOR_THRESHOLD=0.70
MODEL_DETECTOR_FAIL_MODE=warn
AUDIT_LOG_HMAC_KEY=change-me-for-non-development-use
AUDIT_USER_ID_SALT=change-me-for-non-development-use

2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ venv/
*.egg-info/
build/
logs/
backend/data/
evaluation/results/*.csv
datasets/external_splits/*.jsonl

209 changes: 148 additions & 61 deletions README.md

Large diffs are not rendered by default.

127 changes: 114 additions & 13 deletions backend/app/api/proxy.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from __future__ import annotations
from fastapi.middleware.cors import CORSMiddleware

import hmac
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone

from fastapi import FastAPI, Header, HTTPException
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.responses import StreamingResponse

from backend.app.config import get_detection_settings
Expand All @@ -18,6 +20,7 @@
ReasonCodeStatItem,
RecentBlockItem,
)
from backend.app.schemas.auth import AuthCredentials, AuthTokenResponse, AuthUserResponse
from backend.app.schemas.proxy import (
ChatCompletionRequest,
ProxyAnalyzeResponse,
Expand All @@ -31,7 +34,16 @@
get_recent_block_history,
save_audit_log,
)
from backend.app.services.auth_service import (
authenticate_user,
create_user,
issue_token,
resolve_token,
revoke_token,
token_ttl_seconds,
)
from backend.app.services.llm_service import get_upstream_config_summary
from backend.app.services.llm_service import not_called_provider_metadata
from backend.app.services.proxy_service import (
POLICY_PATH,
_detect_text,
Expand All @@ -53,49 +65,135 @@
app = FastAPI()
logger = logging.getLogger(__name__)

app.add_middleware(
CORSMiddleware,
allow_origins=[

def _allowed_ui_origins() -> list[str]:
configured = os.getenv("UI_ALLOWED_ORIGINS", "")
if configured.strip():
return [origin.strip().rstrip("/") for origin in configured.split(",") if origin.strip()]
return [
"http://127.0.0.1:5500",
"http://localhost:5500",
],
]


app.add_middleware(
CORSMiddleware,
allow_origins=_allowed_ui_origins(),
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

_EMAIL_PATTERN = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


@app.on_event("startup")
async def log_detection_configuration() -> None:
settings = get_detection_settings()
logger.info("Detection mode: %s", settings.detection_mode)
logger.info("Model detector enabled: %s", settings.enable_model_detector)

def _admin_api_token() -> str:
return os.getenv("ADMIN_API_TOKEN", "dev-admin-token")
def _admin_api_token() -> str | None:
configured = os.getenv("ADMIN_API_TOKEN", "").strip()
return configured or None


def _require_admin_token(
x_admin_token: str | None = Header(default=None),
) -> None:
if x_admin_token is not None and not isinstance(x_admin_token, str):
return
if x_admin_token != _admin_api_token():
configured = _admin_api_token()
if configured is None:
raise HTTPException(
status_code=503,
detail="관리자 인증이 설정되지 않았습니다.",
)
if not x_admin_token or not hmac.compare_digest(x_admin_token, configured):
raise HTTPException(status_code=401, detail="Unauthorized")


def _normalized_email(credentials: AuthCredentials) -> str:
email = credentials.email.strip().lower()
if not _EMAIL_PATTERN.fullmatch(email):
raise HTTPException(status_code=422, detail="올바른 이메일 형식을 입력해 주세요.")
return email


def _bearer_token(authorization: str | None) -> str | None:
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return None
return token


def _require_authenticated_user(
authorization: str | None = Header(default=None),
) -> str:
email = resolve_token(_bearer_token(authorization))
if not email:
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
return email


@app.post("/auth/signup", response_model=AuthUserResponse, status_code=201)
async def auth_signup(credentials: AuthCredentials) -> AuthUserResponse:
email = _normalized_email(credentials)
if not create_user(email, credentials.password):
raise HTTPException(status_code=409, detail="이미 가입된 이메일입니다.")
return AuthUserResponse(email=email)


@app.post("/auth/login", response_model=AuthTokenResponse)
async def auth_login(credentials: AuthCredentials) -> AuthTokenResponse:
email = _normalized_email(credentials)
if not authenticate_user(email, credentials.password):
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다.")
return AuthTokenResponse(
access_token=issue_token(email),
expires_in=token_ttl_seconds(),
email=email,
)


@app.post("/auth/logout", status_code=204)
async def auth_logout(
authorization: str | None = Header(default=None),
) -> None:
revoke_token(_bearer_token(authorization))


@app.get("/auth/me", response_model=AuthUserResponse)
async def auth_me(
authorization: str | None = Header(default=None),
) -> AuthUserResponse:
email = resolve_token(_bearer_token(authorization))
if not email:
raise HTTPException(status_code=401, detail="인증이 필요합니다.")
return AuthUserResponse(email=email)


@app.post("/proxy/chat")
async def proxy_chat(req: ProxyRequest) -> ProxyResponse:
async def proxy_chat(
req: ProxyRequest,
_email: str = Depends(_require_authenticated_user),
) -> ProxyResponse:
return await process_proxy_chat(req)


@app.post("/proxy/analyze")
async def proxy_analyze(req: ProxyRequest) -> ProxyAnalyzeResponse:
async def proxy_analyze(
req: ProxyRequest,
_email: str = Depends(_require_authenticated_user),
) -> ProxyAnalyzeResponse:
return await process_proxy_analyze(req)


@app.post("/proxy/chat/stream")
async def proxy_chat_stream(req: ProxyRequest) -> StreamingResponse:
async def proxy_chat_stream(
req: ProxyRequest,
_email: str = Depends(_require_authenticated_user),
) -> StreamingResponse:
return StreamingResponse(
process_proxy_chat_stream(req),
media_type="text/event-stream",
Expand Down Expand Up @@ -221,13 +319,16 @@ async def chat_completions(req: ChatCompletionRequest) -> dict:
"reason_codes": final_reasons,
"input_action": action,
"output_action": output_action,
"input_decision": action,
"output_decision": output_action,
"upstream_call": False,
"input": {
**decision.audit_summary,
**audit,
},
"output": output_summary,
"validator": _validator_audit_summary(validator_summary),
**not_called_provider_metadata("mock"),
}
if "hybrid_detection" in audit:
audit_summary["hybrid_detection"] = {
Expand Down
26 changes: 21 additions & 5 deletions backend/app/detection/hybrid_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,24 @@ def detect_hybrid(
)
)
primary = max(combined, key=_priority_key) if combined else None
reason_codes = ordered_reason_codes([item.reason_code for item in combined])
final_action = action_for_reasons(reason_codes) if reason_codes else "ALLOW"
signal_reason_codes = ordered_reason_codes([item.reason_code for item in combined])
operational_reason_codes = ordered_reason_codes(
[
*signal_reason_codes,
*[
reason
for summary in detector_results
if summary.detector == "llm"
and summary.status in {"artifact_missing", "dependency_missing", "error"}
for reason in summary.reasons
],
]
)
final_action = (
action_for_reasons(operational_reason_codes)
if operational_reason_codes
else "ALLOW"
)
detector_counts = _detector_counts(detector_results)
detectors_invoked = [result.detector for result in detector_results]

Expand All @@ -162,10 +178,10 @@ def detect_hybrid(
model_prediction_accepted=model_result.model_prediction_accepted,
model_reason_code=model_result.model_reason_code,
fallback_used=model_result.fallback_used,
reason_codes=reason_codes,
reason_codes=operational_reason_codes,
primary_reason_code=(
select_primary_reason(reason_codes)
if reason_codes
select_primary_reason(signal_reason_codes)
if signal_reason_codes
else primary.reason_code if primary else None
),
risk_score=max((_normalized_score(item) for item in combined), default=0.0),
Expand Down
13 changes: 6 additions & 7 deletions backend/app/detection/lightweight_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,19 +167,19 @@ def _ensure_loaded(self) -> None:
"Lightweight model detector disabled by configuration."
)
return
if joblib is None:
self._status_code = "dependency_missing"
self._status_note = (
"Optional dependency 'joblib' is not installed."
)
return
if (
not self.vectorizer_path.exists()
or not self.classifier_path.exists()
):
self._status_code = "artifact_missing"
self._status_note = "Model artifact files are missing."
return
if joblib is None:
self._status_code = "dependency_missing"
self._status_note = (
"Optional dependency 'joblib' is not installed."
)
return

try:
self._vectorizer = joblib.load(self.vectorizer_path)
Expand All @@ -200,7 +200,6 @@ def _confidence(self, features: Any, predicted_label: str) -> float:
if predicted_label in probabilities_by_class:
return probabilities_by_class[predicted_label]
return max(probabilities_by_class.values())

return 1.0

def prompt_injection_score(self, text: str) -> float:
Expand Down
13 changes: 8 additions & 5 deletions backend/app/detection/model_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,10 +404,13 @@ def detect_model(
if not classifier_status.enabled
else None
)
reasons = list(signal_reasons)
if fallback_reason is not None:
reasons = ordered_reason_codes([*reasons, fallback_reason])
reasons = ordered_reason_codes(reasons)
# Model availability is an operational status, not a user-content
# finding. Keep it in the detector summary for auditability without
# turning otherwise safe input into WARN or affecting policy action.
reasons = ordered_reason_codes(signal_reasons)
summary_reasons = ordered_reason_codes(
[*signal_reasons, fallback_reason] if fallback_reason else signal_reasons
)
confidence = _fallback_confidence(signal_reasons, prediction)
action = action_for_reasons(reasons) if reasons else "ALLOW"
status = "enabled" if classifier_status.enabled else classifier_status.status
Expand All @@ -422,7 +425,7 @@ def detect_model(
summary = DetectorRunSummary(
detector="llm",
action=summary_action,
reasons=reasons,
reasons=summary_reasons,
pii_detected=any(_detection_type(reason) == DetectorType.PII for reason in reasons),
injection_detected=any(_detection_type(reason) == DetectorType.INJECTION for reason in reasons),
confidence=(
Expand Down
3 changes: 3 additions & 0 deletions backend/app/detection/reason_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ReasonCode(str, Enum):
PII_ACCOUNT_DETECTED = "PII_ACCOUNT_DETECTED"
PII_REQUEST_RRN = "PII_REQUEST_RRN"
PII_EXFILTRATION_REQUEST = "PII_EXFILTRATION_REQUEST"
PII_UNMASKABLE_DETECTED = "PII_UNMASKABLE_DETECTED"
MODEL_PII_RISK = "MODEL_PII_RISK"
MODEL_DETECTOR_ERROR = "MODEL_DETECTOR_ERROR"
MODEL_ARTIFACT_MISSING = "MODEL_ARTIFACT_MISSING"
Expand Down Expand Up @@ -59,6 +60,7 @@ class ReasonCode(str, Enum):
ReasonCode.INJ_IGNORE_PREVIOUS_INSTRUCTIONS.value,
ReasonCode.PII_REQUEST_RRN.value,
ReasonCode.PII_EXFILTRATION_REQUEST.value,
ReasonCode.PII_UNMASKABLE_DETECTED.value,
ReasonCode.PII_RRN_DETECTED.value,
ReasonCode.PII_PHONE_DETECTED.value,
ReasonCode.PII_EMAIL_OBFUSCATED.value,
Expand All @@ -81,6 +83,7 @@ class ReasonCode(str, Enum):
ReasonCode.PII_ACCOUNT_DETECTED.value: PolicyAction.WARN.value,
ReasonCode.PII_REQUEST_RRN.value: PolicyAction.BLOCK.value,
ReasonCode.PII_EXFILTRATION_REQUEST.value: PolicyAction.BLOCK.value,
ReasonCode.PII_UNMASKABLE_DETECTED.value: PolicyAction.BLOCK.value,
ReasonCode.MODEL_PII_RISK.value: PolicyAction.WARN.value,
ReasonCode.MODEL_DETECTOR_ERROR.value: PolicyAction.WARN.value,
ReasonCode.MODEL_ARTIFACT_MISSING.value: PolicyAction.WARN.value,
Expand Down
4 changes: 2 additions & 2 deletions backend/app/engine/policy_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def _injection_audit(eligible: list[tuple[DetectionResult, PolicyRule]]) -> dict
)
winning_detection, winning_rule = max(
injection_items,
key=lambda item: (item[1].priority, _ACTION_WEIGHT[item[1].action]),
key=lambda item: (_ACTION_WEIGHT[item[1].action], item[1].priority),
)

return {
Expand Down Expand Up @@ -149,7 +149,7 @@ def evaluate_policy(

winner_detection, winner_rule = max(
eligible,
key=lambda item: (item[1].priority, _ACTION_WEIGHT[item[1].action]),
key=lambda item: (_ACTION_WEIGHT[item[1].action], item[1].priority),
)
reasons = ordered_reason_codes([item[0].reason_code for item in eligible])

Expand Down
Loading
Loading