Spaces:
Sleeping
Sleeping
File size: 18,089 Bytes
7d67a69 14640e3 7d67a69 14640e3 4996b79 14640e3 fb04509 7d67a69 51c5a22 14640e3 7d67a69 fb04509 97a461c 7d67a69 14640e3 4996b79 14640e3 fb04509 14640e3 7d67a69 14640e3 7d67a69 fb04509 7d67a69 fb04509 7d67a69 14640e3 7d67a69 fb04509 7d67a69 fb04509 14640e3 fb04509 14640e3 fb04509 7d67a69 fb04509 7d67a69 fb04509 7d67a69 14640e3 7d67a69 fb04509 7d67a69 fb04509 14640e3 fb04509 14640e3 7d67a69 14640e3 7d67a69 14640e3 51c5a22 97a461c 51c5a22 7d67a69 14640e3 7d67a69 14640e3 97a461c 7d67a69 14640e3 97a461c 14640e3 7d67a69 14640e3 7d67a69 14640e3 7d67a69 14640e3 7d67a69 14640e3 7d67a69 14640e3 51c5a22 97a461c 51c5a22 14640e3 97a461c 4996b79 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 | # api.py
# 此檔案定義所有 API 端點
# 包含統計數據、日誌查詢、圖表數據等
from fastapi import FastAPI, Query, Body
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional, List
import os
from groq import Groq
from auth import (
simulate_click, USER_DB, check_session_timeouts, get_user_status,
internal_process_login, internal_logout
)
from security import test_send_security_alert, verify_identity
from config import SYSTEM_WEIGHTS, ACTION_MAP, FIXED_TEST_EMAIL
from plotting import get_plot_data_for_api
import pandas as pd
import models # 使用 models.XXX 存取即時狀態,避免布林值被複製後失效
from utils import get_geo_level, update_system_weights
api_app = FastAPI(title="數位保鏢 API")
# --- Pydantic Models ---
class AnalyzeLogRequest(BaseModel):
log_data: str # JSON 格式的 log 字串
event_desc: Optional[str] = ""
event_ip: Optional[str] = ""
event_status: Optional[str] = ""
class WeightsRequest(BaseModel):
ow: float
tw: float
iw: float
dw: float
gw: float
fw: float
class LoginRequest(BaseModel):
account: str
password: str
captcha: str
ip: str
cookie: str
time_mode: str
custom_time: Optional[str] = ""
class StudentActionRequest(BaseModel):
ip: str
cookie: str
time_mode: str
custom_time: Optional[str] = ""
class StudentActionExtendedRequest(StudentActionRequest):
account: str
class GenericActionRequest(StudentActionRequest):
account: str
action_code: str
# --- 純淨 API 端點專用函式 (供內部呼叫) ---
def internal_get_stats():
"""取得系統狀態與權重"""
total = models.global_stats["total_count"]
abnormal = models.global_stats["abnormal_count"]
rate = round((abnormal / total * 100), 2) if total > 0 else 0
return {
"status": "success",
"ai_ready": models.AI_READY,
"data": {
"total_logs": total,
"abnormal_logs": abnormal,
"anomaly_rate_percent": rate,
"current_weights": SYSTEM_WEIGHTS
}
}
def internal_get_logs(limit=20):
"""取得最新的 N 筆 Log"""
return {
"status": "success",
"count": len(models.log_history[:limit]),
"data": models.log_history[:limit]
}
def _extract_gr_update_value(upd):
"""從 Gradio update 物件中提取 value (如果有)"""
if hasattr(upd, 'value'):
return upd.value
if isinstance(upd, dict) and 'value' in upd:
return upd['value']
return ""
# --- FastAPI 路由定義 ---
@api_app.get("/api/stats")
def get_stats():
return internal_get_stats()
@api_app.get("/api/logs")
def get_logs(limit: int = 20):
return internal_get_logs(limit)
@api_app.post("/api/login")
def login_endpoint(req: LoginRequest):
return internal_process_login(
req.ip, req.cookie, req.account, req.password,
req.captcha, req.time_mode, req.custom_time
)
@api_app.post("/api/logout")
def logout_endpoint(req: StudentActionExtendedRequest):
return internal_logout(
req.ip, req.cookie, req.account, req.time_mode, req.custom_time
)
@api_app.get("/api/chart_data")
def get_chart_data(action_name: str = Query(...)):
"""取得特定系統的圖表原始數據"""
if not models.DATA_READY:
return {"status": "error", "message": "歷史基準資料未載入,請確認 baseline_logs.csv 是否存在或雲端同步是否正常"}
# 嘗試從顯示名稱映射到代碼,如果找不到,假設輸入已經是代碼
action_code = ACTION_MAP.get(action_name) or action_name
# 驗證 action_code 是否有效 (在 baseline_df 中存在或在 ACTION_MAP 的值中)
valid_codes = set(ACTION_MAP.values())
if action_code not in valid_codes and action_code not in models.baseline_df['action'].unique():
return {"status": "error", "message": f"找不到系統代碼或名稱: {action_name}"}
df_hist = models.baseline_df[models.baseline_df['action'] == action_code].copy()
df_live = pd.DataFrame(models.log_history)
if not df_live.empty:
df_live['timestamp'] = pd.to_datetime(df_live['timestamp'], errors='coerce')
df_live = df_live.dropna(subset=['timestamp'])
df_live = df_live[df_live['action'] == action_code].copy()
if not df_live.empty:
df_combined = pd.concat([df_hist, df_live], ignore_index=True)
df_combined = df_combined.drop_duplicates(subset=['timestamp', 'account', 'action'])
else:
df_combined = df_hist.copy()
if df_combined.empty:
return {"status": "success", "action": action_code, "data": {"normal": [], "abnormal": []}}
df_combined['month'] = df_combined['timestamp'].dt.month
df_combined['time_of_day'] = df_combined['timestamp'].dt.hour + df_combined['timestamp'].dt.minute / 60.0
is_abnormal = df_combined['status'].astype(str).str.contains("異常", na=False)
normal_data = df_combined[~is_abnormal][['month', 'time_of_day', 'timestamp']].astype(str).to_dict('records')
abnormal_data = df_combined[is_abnormal][['month', 'time_of_day', 'timestamp', 'status']].astype(str).to_dict('records')
return {
"status": "success",
"action_code": action_code,
"data": {"normal": normal_data, "abnormal": abnormal_data}
}
@api_app.get("/api/abnormal_logs")
def get_abnormal_logs():
"""取得所有異常 Log"""
abnormal_logs = [log for log in models.log_history if "異常" in str(log.get("status", "")) or "錯誤" in str(log.get("status", "")) or "受限" in str(log.get("status", ""))]
return {
"status": "success",
"count": len(abnormal_logs),
"data": abnormal_logs
}
@api_app.get("/api/get_user_logs")
def get_user_logs(session_id: str = Query(...)):
"""
獲取指定 session_id (cookie_id) 的所有日誌
過濾自 Hugging Face 同步的歷史數據與當前記憶體數據
"""
# 1. 準備過濾後的數據
filtered_logs = []
# 從歷史數據中過濾 (如果有的話)
if models.baseline_df is not None and not models.baseline_df.empty:
# 確保資料格式一致
df_filtered = models.baseline_df[models.baseline_df['cookie_id'] == session_id].copy()
if not df_filtered.empty:
df_filtered['timestamp'] = pd.to_datetime(df_filtered['timestamp']).dt.strftime('%Y-%m-%d %H:%M:%S')
filtered_logs.extend(df_filtered.to_dict('records'))
# 從當前記憶體日誌中過濾
mem_logs = [log for log in models.log_history if log.get("cookie_id") == session_id]
filtered_logs.extend(mem_logs)
# 2. 去重並排序 (依時間倒序)
# 使用 timestamp + action 作為簡單的去重基準
seen = set()
unique_logs = []
for log in filtered_logs:
key = (log.get('timestamp'), log.get('action'))
if key not in seen:
seen.add(key)
unique_logs.append(log)
unique_logs.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
# 3. 豐富化資料 (加上地理位置、設備資訊、判定狀態)
final_logs = []
for log in unique_logs:
ip = log.get('ip_address', 'Unknown')
geo_code = get_geo_level(ip)
# 地理位置映射
geo_map = {
0: "台北市 (校內專網)",
1: "台北市 (校園網路)",
2: "台灣 (宿舍/寬頻)",
3: "台灣 (公共網路)",
4: "美國 (海外連線)",
5: "未知區域"
}
location = geo_map.get(geo_code, "未知區域")
# 設備資訊 (目前資料集無此欄位,給予模擬值或依 IP 推斷)
# 這裡模擬一些常見設備
device = "PC (Chrome/Windows)" if "140.124" in ip else "Mobile (Safari/iOS)"
# AI 判定狀態轉為中文
status_raw = str(log.get('status', 'success'))
ai_status = "異常" if ("異常" in status_raw or "受限" in status_raw or "錯誤" in status_raw) else "正常"
final_logs.append({
"time": log.get('timestamp'),
"action": log.get('action'),
"ip": ip,
"location": location,
"device": device,
"ai_status": ai_status,
"status_detail": status_raw # 保留原始詳細資訊
})
return {
"status": "success",
"session_id": session_id,
"count": len(final_logs),
"data": final_logs
}
@api_app.get("/api/chart_html")
def get_chart_html(action_name: str = Query(...)):
"""取得圖表的 HTML 內容 (Gradio 專用)"""
from plotting import get_chart_js_html
html_data = get_chart_js_html(action_name)
return {"status": "success", "html": html_data}
@api_app.get("/api/plot_image")
def get_plot_image(action_name: str = Query(...)):
"""取得圖表的 Base64 圖片"""
if not models.DATA_READY:
return {"status": "error", "message": "歷史基準資料未載入"}
img_data = get_plot_data_for_api(action_name)
return {"status": "success", "image": img_data}
@api_app.post("/api/update_weights")
def update_weights(req: WeightsRequest):
"""更新系統權重"""
msg = update_system_weights(req.ow, req.tw, req.iw, req.dw, req.gw, req.fw)
return {"status": "success", "message": msg}
@api_app.get("/verify_identity")
def verify_identity_endpoint(token: str = Query(...), choice: str = Query(...)):
return verify_identity(token, choice)
@api_app.get("/test_send_security_alert")
def test_send_security_alert_endpoint():
return test_send_security_alert()
# --- Student Actions ---
def _run_student_action_helper(ip, c, acc, action_code, tm, ct):
# 每次請求時檢查超時
check_session_timeouts(timeout_seconds=300)
# 直接以帳戶名稱作為 key 查詢封鎖狀態
current_status = get_user_status(acc)
if current_status == "verifying":
return {
"status": "pending_verify",
"message": "偵測到異常,請於5分鐘內確認 Email 以繼續使用"
}
elif current_status == "blocked":
return {
"status": "blocked",
"message": "帳號已被封鎖,請聯絡系統管理員或寄驗證信進行解封"
}
df_all, df_abn, inp_upd = simulate_click(ip, c, acc, action_code, tm, ct)
val = _extract_gr_update_value(inp_upd)
return {
"status": "success",
"anomaly_msg": val or "",
"action": action_code
}
@api_app.post("/api/student/eval")
def student_eval(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_eval_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/pre_select")
def student_pre_select(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_pre_select_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/leave")
def student_leave(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_leave_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/dorm")
def student_dorm(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_dorm_system", req.time_mode, req.custom_time)
@api_app.post("/api/student/webmail")
def student_webmail(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_webmail", req.time_mode, req.custom_time)
@api_app.post("/api/student/vdesk")
def student_vdesk(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "click_vdesk", req.time_mode, req.custom_time)
@api_app.post("/api/student/sql_injection")
def student_sql_injection(req: StudentActionExtendedRequest):
return _run_student_action_helper(req.ip, req.cookie, req.account, "malicious_sql_injection", req.time_mode, req.custom_time)
@api_app.post("/api/student/run_action")
def student_run_action(req: GenericActionRequest):
"""通用學生操作端點"""
# 每次請求時檢查超時
check_session_timeouts(timeout_seconds=300)
# 直接以帳戶名稱作為 key 查詢封鎖狀態
current_status = get_user_status(req.account)
if current_status == "verifying":
return {
"status": "pending_verify",
"message": "偵測到異常,請於5分鐘內確認 Email 以繼續使用"
}
elif current_status == "blocked":
return {
"status": "blocked",
"message": "帳號已被封鎖,請聯絡系統管理員或寄驗證信進行解封"
}
df_all, df_abn, inp_upd = simulate_click(req.ip, req.cookie, req.account, req.action_code, req.time_mode, req.custom_time)
val = _extract_gr_update_value(inp_upd)
return {
"status": "success",
"anomaly_msg": val or "",
# 為了 Vue 前端可能需要的資料更新,回傳簡化後的 log
"log_entry": {
"timestamp": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S"), # 這裡簡化處理
"action": req.action_code,
"status": val or "success"
}
}
# --- 使用者管理 API 端點 ---
class AccountRequest(BaseModel):
account: str
@api_app.get("/api/users")
def get_users_endpoint():
"""取得所有使用者帳戶清單與其狀態"""
users_list = []
for acc, info in USER_DB.items():
role_en = "admin" if info[1] == "管理" else "student"
status = get_user_status(acc)
users_list.append({
"account": acc,
"role": role_en,
"role_zh": info[1],
"name": info[2],
"status": status
})
return {"status": "success", "data": users_list}
@api_app.post("/api/users/block")
def block_user_endpoint(req: AccountRequest):
"""封鎖特定使用者"""
acc = req.account.strip()
if acc not in USER_DB:
return {"status": "error", "message": "帳號不存在"}
from auth import session_cache_lock, session_cache
with session_cache_lock:
if acc not in session_cache:
session_cache[acc] = {}
session_cache[acc]["status"] = "blocked"
session_cache[acc]["pending_token"] = None
session_cache[acc]["anomaly_timestamp"] = None
return {"status": "success", "message": f"帳號 {acc} 已成功封鎖"}
@api_app.post("/api/users/unblock")
def unblock_user_endpoint(req: AccountRequest):
"""解鎖特定使用者"""
acc = req.account.strip()
if acc not in USER_DB:
return {"status": "error", "message": "帳號不存在"}
from auth import unblock_user
unblock_user(acc)
return {"status": "success", "message": f"帳號 {acc} 已成功解封"}
# --- Groq AI 分析端點 ---
@api_app.post("/api/analyze_log")
def analyze_log_with_groq(req: AnalyzeLogRequest):
"""
使用 Groq AI 分析異常 Log 事件。
接收原始 Log JSON 字串,呼叫 Groq API 進行資安分析,回傳中文解析結果。
"""
groq_api_key = os.getenv("GROQ_API_KEY", "")
if not groq_api_key:
return {
"status": "error",
"message": "Groq API Key 未設定,請確認後端 .env 檔案中的 GROQ_API_KEY"
}
try:
client = Groq(api_key=groq_api_key)
# 建構 Prompt
prompt_parts = []
prompt_parts.append("你是一位資訊安全專家,正在分析一筆校園系統的異常存取 Log。")
prompt_parts.append("")
if req.event_ip:
prompt_parts.append(f"來源 IP:{req.event_ip}")
if req.event_status:
prompt_parts.append(f"AI 判定狀態:{req.event_status}")
if req.event_desc:
prompt_parts.append(f"事件描述:{req.event_desc}")
prompt_parts.append("")
prompt_parts.append("原始 Log 數據(JSON 格式):")
prompt_parts.append(req.log_data)
prompt_parts.append("")
prompt_parts.append("請根據以上資訊,以繁體中文提供:")
prompt_parts.append("1. 可能的攻擊類型或異常行為說明(1-2句)")
prompt_parts.append("2. 風險等級評估(低/中/高)並說明原因")
prompt_parts.append("3. 具體的建議處理動作(2-4點條列)")
prompt_parts.append("")
prompt_parts.append("請直接輸出分析內容,格式簡潔專業,不要加入多餘的標題或引號。")
full_prompt = "\n".join(prompt_parts)
chat_completion = client.chat.completions.create(
messages=[
{
"role": "system",
"content": "你是一位熟悉台灣校園資訊安全的資安分析師,專精於識別異常存取行為、帳號盜用與惡意攻擊。請以繁體中文回答。"
},
{
"role": "user",
"content": full_prompt
}
],
model="llama-3.3-70b-versatile",
temperature=0.4,
max_tokens=512,
)
analysis_text = chat_completion.choices[0].message.content or "無法生成分析結果。"
return {
"status": "success",
"analysis": analysis_text,
"model": chat_completion.model,
"usage": {
"prompt_tokens": chat_completion.usage.prompt_tokens,
"completion_tokens": chat_completion.usage.completion_tokens,
}
}
except Exception as e:
error_msg = str(e)
print(f"[Groq API Error] {error_msg}")
return {
"status": "error",
"message": f"Groq AI 分析失敗:{error_msg}"
} |