主要更新: - 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等) - Dockerfile 添加 curl 以支持 Docker 健康检查 - 前端页面更新 (管理后台、用户端) - 数据库迁移和 schema 更新 - 新增 kdocs 上传服务 - 添加安全相关测试用例 Co-Authored-By: Claude <noreply@anthropic.com>
390 lines
14 KiB
Python
390 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from datetime import timedelta
|
||
from typing import Optional
|
||
|
||
import db_pool
|
||
from db.utils import get_cst_now, get_cst_now_str, parse_cst_datetime
|
||
|
||
from . import constants as C
|
||
from .blacklist import BlacklistManager
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _ScoreUpdateResult:
|
||
ip_score: int
|
||
user_score: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _BanAction:
|
||
reason: str
|
||
duration_hours: Optional[int] = None
|
||
permanent: bool = False
|
||
|
||
|
||
class RiskScorer:
|
||
"""风险评分引擎 - 计算IP和用户的风险分数"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
auto_ban_enabled: bool = True,
|
||
auto_ban_duration_hours: int = 24,
|
||
high_risk_threshold: int = 80,
|
||
high_risk_window_hours: int = 1,
|
||
high_risk_permanent_ban_count: int = 3,
|
||
blacklist_manager: Optional[BlacklistManager] = None,
|
||
) -> None:
|
||
self.auto_ban_enabled = bool(auto_ban_enabled)
|
||
self.auto_ban_duration_hours = max(1, int(auto_ban_duration_hours))
|
||
self.high_risk_threshold = max(0, int(high_risk_threshold))
|
||
self.high_risk_window_hours = max(1, int(high_risk_window_hours))
|
||
self.high_risk_permanent_ban_count = max(1, int(high_risk_permanent_ban_count))
|
||
self.blacklist = blacklist_manager or BlacklistManager()
|
||
|
||
def get_ip_score(self, ip_address: str) -> int:
|
||
"""获取IP风险分(0-100),从数据库读取"""
|
||
ip_text = str(ip_address or "").strip()[:64]
|
||
if not ip_text:
|
||
return 0
|
||
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT risk_score FROM ip_risk_scores WHERE ip = ?", (ip_text,))
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return 0
|
||
try:
|
||
return max(0, min(100, int(row["risk_score"])))
|
||
except Exception:
|
||
return 0
|
||
|
||
def get_user_score(self, user_id: int) -> int:
|
||
"""获取用户风险分(0-100)"""
|
||
if user_id is None:
|
||
return 0
|
||
user_id_int = int(user_id)
|
||
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT risk_score FROM user_risk_scores WHERE user_id = ?", (user_id_int,))
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return 0
|
||
try:
|
||
return max(0, min(100, int(row["risk_score"])))
|
||
except Exception:
|
||
return 0
|
||
|
||
def get_combined_score(self, ip: str, user_id: int = None) -> int:
|
||
"""综合风险分 = max(IP分, 用户分) + 行为加成"""
|
||
base = max(self.get_ip_score(ip), self.get_user_score(user_id) if user_id is not None else 0)
|
||
bonus = self._get_behavior_bonus(ip, user_id)
|
||
return max(0, min(100, int(base + bonus)))
|
||
|
||
def record_threat(
|
||
self,
|
||
ip: str,
|
||
user_id: int,
|
||
threat_type: str,
|
||
score: int,
|
||
request_path: str = None,
|
||
payload: str = None,
|
||
):
|
||
"""记录威胁事件到数据库,并更新IP/用户风险分"""
|
||
ip_text = str(ip or "").strip()[:64]
|
||
user_id_int = int(user_id) if user_id is not None else None
|
||
threat_type_text = str(threat_type or "").strip()[:64] or "unknown"
|
||
score_int = max(0, int(score))
|
||
path_text = str(request_path or "").strip()[:512] if request_path else None
|
||
payload_text = str(payload or "").strip() if payload else None
|
||
if payload_text and len(payload_text) > 2048:
|
||
payload_text = payload_text[:2048]
|
||
|
||
now_str = get_cst_now_str()
|
||
|
||
ip_ban_action: Optional[_BanAction] = None
|
||
user_ban_action: Optional[_BanAction] = None
|
||
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO threat_events (
|
||
threat_type, score, ip, user_id, request_path, value_preview, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
threat_type_text,
|
||
score_int,
|
||
ip_text or None,
|
||
user_id_int,
|
||
path_text,
|
||
payload_text,
|
||
now_str,
|
||
),
|
||
)
|
||
|
||
update = self._update_scores(cursor, ip_text, user_id_int, score_int, now_str)
|
||
|
||
if self.auto_ban_enabled:
|
||
ip_ban_action, user_ban_action = self._get_auto_ban_actions(
|
||
cursor,
|
||
ip_text,
|
||
user_id_int,
|
||
threat_type_text,
|
||
score_int,
|
||
update,
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
if not self.auto_ban_enabled:
|
||
return
|
||
|
||
if ip_ban_action and ip_text:
|
||
self.blacklist.ban_ip(
|
||
ip_text,
|
||
reason=ip_ban_action.reason,
|
||
duration_hours=ip_ban_action.duration_hours or self.auto_ban_duration_hours,
|
||
permanent=ip_ban_action.permanent,
|
||
)
|
||
if user_ban_action and user_id_int is not None:
|
||
self.blacklist._ban_user_internal(
|
||
user_id_int,
|
||
reason=user_ban_action.reason,
|
||
duration_hours=user_ban_action.duration_hours or self.auto_ban_duration_hours,
|
||
permanent=user_ban_action.permanent,
|
||
)
|
||
|
||
def decay_scores(self):
|
||
"""风险分衰减 - 定期调用,降低历史风险分"""
|
||
now = get_cst_now()
|
||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute("SELECT ip, risk_score, updated_at, created_at FROM ip_risk_scores")
|
||
for row in cursor.fetchall():
|
||
ip = row["ip"]
|
||
current_score = int(row["risk_score"] or 0)
|
||
updated_at = row["updated_at"] or row["created_at"]
|
||
hours = self._hours_since(updated_at, now)
|
||
if hours <= 0:
|
||
continue
|
||
new_score = self._apply_hourly_decay(current_score, hours)
|
||
if new_score == current_score:
|
||
continue
|
||
cursor.execute(
|
||
"UPDATE ip_risk_scores SET risk_score = ?, updated_at = ? WHERE ip = ?",
|
||
(new_score, now_str, ip),
|
||
)
|
||
|
||
cursor.execute("SELECT user_id, risk_score, updated_at, created_at FROM user_risk_scores")
|
||
for row in cursor.fetchall():
|
||
user_id = int(row["user_id"])
|
||
current_score = int(row["risk_score"] or 0)
|
||
updated_at = row["updated_at"] or row["created_at"]
|
||
hours = self._hours_since(updated_at, now)
|
||
if hours <= 0:
|
||
continue
|
||
new_score = self._apply_hourly_decay(current_score, hours)
|
||
if new_score == current_score:
|
||
continue
|
||
cursor.execute(
|
||
"UPDATE user_risk_scores SET risk_score = ?, updated_at = ? WHERE user_id = ?",
|
||
(new_score, now_str, user_id),
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
def _update_ip_score(self, ip: str, score_delta: int):
|
||
"""更新IP风险分"""
|
||
ip_text = str(ip or "").strip()[:64]
|
||
if not ip_text:
|
||
return
|
||
delta = int(score_delta)
|
||
now_str = get_cst_now_str()
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
self._update_scores(cursor, ip_text, None, delta, now_str)
|
||
conn.commit()
|
||
|
||
def _update_user_score(self, user_id: int, score_delta: int):
|
||
"""更新用户风险分"""
|
||
if user_id is None:
|
||
return
|
||
user_id_int = int(user_id)
|
||
delta = int(score_delta)
|
||
now_str = get_cst_now_str()
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
self._update_scores(cursor, "", user_id_int, delta, now_str)
|
||
conn.commit()
|
||
|
||
def reset_ip_score(self, ip: str) -> bool:
|
||
"""清零指定IP的风险分"""
|
||
ip_text = str(ip or "").strip()[:64]
|
||
if not ip_text:
|
||
return False
|
||
|
||
now_str = get_cst_now_str()
|
||
with db_pool.get_db() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT ip FROM ip_risk_scores WHERE ip = ?", (ip_text,))
|
||
row = cursor.fetchone()
|
||
if row:
|
||
cursor.execute(
|
||
"UPDATE ip_risk_scores SET risk_score = 0, last_seen = ?, updated_at = ? WHERE ip = ?",
|
||
(now_str, now_str, ip_text),
|
||
)
|
||
else:
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
|
||
VALUES (?, 0, ?, ?, ?)
|
||
""",
|
||
(ip_text, now_str, now_str, now_str),
|
||
)
|
||
conn.commit()
|
||
return True
|
||
|
||
def _update_scores(
|
||
self,
|
||
cursor,
|
||
ip: str,
|
||
user_id: Optional[int],
|
||
score_delta: int,
|
||
now_str: str,
|
||
) -> _ScoreUpdateResult:
|
||
ip_score = 0
|
||
user_score = 0
|
||
|
||
if ip:
|
||
cursor.execute("SELECT risk_score FROM ip_risk_scores WHERE ip = ?", (ip,))
|
||
row = cursor.fetchone()
|
||
current = int(row["risk_score"]) if row else 0
|
||
ip_score = max(0, min(100, current + int(score_delta)))
|
||
if row:
|
||
cursor.execute(
|
||
"UPDATE ip_risk_scores SET risk_score = ?, last_seen = ?, updated_at = ? WHERE ip = ?",
|
||
(ip_score, now_str, now_str, ip),
|
||
)
|
||
else:
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO ip_risk_scores (ip, risk_score, last_seen, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(ip, ip_score, now_str, now_str, now_str),
|
||
)
|
||
|
||
if user_id is not None:
|
||
cursor.execute("SELECT risk_score FROM user_risk_scores WHERE user_id = ?", (int(user_id),))
|
||
row = cursor.fetchone()
|
||
current = int(row["risk_score"]) if row else 0
|
||
user_score = max(0, min(100, current + int(score_delta)))
|
||
if row:
|
||
cursor.execute(
|
||
"UPDATE user_risk_scores SET risk_score = ?, last_seen = ?, updated_at = ? WHERE user_id = ?",
|
||
(user_score, now_str, now_str, int(user_id)),
|
||
)
|
||
else:
|
||
cursor.execute(
|
||
"""
|
||
INSERT INTO user_risk_scores (user_id, risk_score, last_seen, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""",
|
||
(int(user_id), user_score, now_str, now_str, now_str),
|
||
)
|
||
|
||
return _ScoreUpdateResult(ip_score=ip_score, user_score=user_score)
|
||
|
||
def _get_auto_ban_actions(
|
||
self,
|
||
cursor,
|
||
ip: str,
|
||
user_id: Optional[int],
|
||
threat_type: str,
|
||
score: int,
|
||
update: _ScoreUpdateResult,
|
||
) -> tuple[Optional["_BanAction"], Optional["_BanAction"]]:
|
||
ip_action: Optional[_BanAction] = None
|
||
user_action: Optional[_BanAction] = None
|
||
|
||
if threat_type == C.THREAT_TYPE_JNDI_INJECTION:
|
||
if ip:
|
||
ip_action = _BanAction(reason="JNDI injection detected", permanent=True)
|
||
if user_id is not None:
|
||
user_action = _BanAction(reason="JNDI injection detected", permanent=True)
|
||
return ip_action, user_action
|
||
|
||
if ip and update.ip_score >= 100:
|
||
ip_action = _BanAction(reason="Risk score reached 100", duration_hours=self.auto_ban_duration_hours)
|
||
if user_id is not None and update.user_score >= 100:
|
||
user_action = _BanAction(reason="Risk score reached 100", duration_hours=self.auto_ban_duration_hours)
|
||
|
||
if score < self.high_risk_threshold:
|
||
return ip_action, user_action
|
||
|
||
window_start = (get_cst_now() - timedelta(hours=self.high_risk_window_hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
if ip:
|
||
cursor.execute(
|
||
"""
|
||
SELECT COUNT(*) AS cnt
|
||
FROM threat_events
|
||
WHERE ip = ? AND score >= ? AND created_at >= ?
|
||
""",
|
||
(ip, int(self.high_risk_threshold), window_start),
|
||
)
|
||
row = cursor.fetchone()
|
||
cnt = int(row["cnt"]) if row else 0
|
||
if cnt >= self.high_risk_permanent_ban_count:
|
||
ip_action = _BanAction(reason="High-risk threats threshold reached", permanent=True)
|
||
|
||
if user_id is not None:
|
||
cursor.execute(
|
||
"""
|
||
SELECT COUNT(*) AS cnt
|
||
FROM threat_events
|
||
WHERE user_id = ? AND score >= ? AND created_at >= ?
|
||
""",
|
||
(int(user_id), int(self.high_risk_threshold), window_start),
|
||
)
|
||
row = cursor.fetchone()
|
||
cnt = int(row["cnt"]) if row else 0
|
||
if cnt >= self.high_risk_permanent_ban_count:
|
||
user_action = _BanAction(reason="High-risk threats threshold reached", permanent=True)
|
||
|
||
return ip_action, user_action
|
||
|
||
def _get_behavior_bonus(self, ip: str, user_id: Optional[int]) -> int:
|
||
return 0
|
||
|
||
def _hours_since(self, dt_str: Optional[str], now) -> int:
|
||
if not dt_str:
|
||
return 0
|
||
try:
|
||
dt = parse_cst_datetime(str(dt_str))
|
||
except Exception:
|
||
return 0
|
||
seconds = (now - dt).total_seconds()
|
||
if seconds <= 0:
|
||
return 0
|
||
return int(seconds // 3600)
|
||
|
||
def _apply_hourly_decay(self, score: int, hours: int) -> int:
|
||
score_int = max(0, int(score))
|
||
if score_int <= 0 or hours <= 0:
|
||
return score_int
|
||
decayed = int(math.floor(score_int * (0.9**int(hours))))
|
||
return max(0, min(100, decayed))
|