feat: 实现完整安全防护系统
Phase 1 - 威胁检测引擎: - security/threat_detector.py: JNDI/SQL/XSS/路径遍历/命令注入检测 - security/constants.py: 威胁检测规则和评分常量 - 数据库表: threat_events, ip_risk_scores, user_risk_scores, ip_blacklist Phase 2 - 风险评分与黑名单: - security/risk_scorer.py: IP/用户风险评分引擎,支持分数衰减 - security/blacklist.py: 黑名单管理,自动封禁规则 Phase 3 - 响应策略: - security/honeypot.py: 蜜罐响应生成器 - security/response_handler.py: 渐进式响应策略 Phase 4 - 集成: - security/middleware.py: Flask安全中间件 - routes/admin_api/security.py: 管理后台安全仪表板API - 36个测试用例全部通过 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
def register_blueprints(app) -> None:
|
||||
from routes.admin_api import admin_api_bp
|
||||
from routes.admin_api import security_bp as admin_security_bp
|
||||
from routes.api_accounts import api_accounts_bp
|
||||
from routes.api_auth import api_auth_bp
|
||||
from routes.api_schedules import api_schedules_bp
|
||||
@@ -21,3 +22,6 @@ def register_blueprints(app) -> None:
|
||||
app.register_blueprint(api_screenshots_bp)
|
||||
app.register_blueprint(api_schedules_bp)
|
||||
app.register_blueprint(admin_api_bp)
|
||||
# Security admin APIs (support both /api/admin/* and /yuyx/api/admin/*)
|
||||
app.register_blueprint(admin_security_bp)
|
||||
app.register_blueprint(admin_security_bp, url_prefix="/yuyx", name="admin_security_yuyx")
|
||||
|
||||
@@ -9,3 +9,6 @@ admin_api_bp = Blueprint("admin_api", __name__, url_prefix="/yuyx/api")
|
||||
# Import side effects: register routes on blueprint
|
||||
from routes.admin_api import core as _core # noqa: F401
|
||||
from routes.admin_api import update as _update # noqa: F401
|
||||
|
||||
# Export security blueprint for app registration
|
||||
from routes.admin_api.security import security_bp # noqa: F401
|
||||
|
||||
334
routes/admin_api/security.py
Normal file
334
routes/admin_api/security.py
Normal file
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
import db_pool
|
||||
from db import security as security_db
|
||||
from routes.decorators import admin_required
|
||||
from security import BlacklistManager, RiskScorer
|
||||
|
||||
security_bp = Blueprint("admin_security", __name__)
|
||||
blacklist = BlacklistManager()
|
||||
scorer = RiskScorer(blacklist_manager=blacklist)
|
||||
|
||||
|
||||
def _truncate(value: Any, max_len: int = 200) -> str:
|
||||
text = str(value or "")
|
||||
if max_len <= 0:
|
||||
return ""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max(0, max_len - 3)] + "..."
|
||||
|
||||
|
||||
def _parse_int_arg(name: str, default: int, *, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||
raw = request.args.get(name, None)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
value = int(default)
|
||||
else:
|
||||
try:
|
||||
value = int(str(raw).strip())
|
||||
except Exception:
|
||||
value = int(default)
|
||||
|
||||
if min_value is not None:
|
||||
value = max(int(min_value), value)
|
||||
if max_value is not None:
|
||||
value = min(int(max_value), value)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_json() -> dict:
|
||||
if request.is_json:
|
||||
data = request.get_json(silent=True) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
# 兼容 form-data
|
||||
try:
|
||||
return dict(request.form or {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
text = str(value or "").strip().lower()
|
||||
return text in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _sanitize_threat_event(event: dict) -> dict:
|
||||
return {
|
||||
"id": event.get("id"),
|
||||
"threat_type": event.get("threat_type") or "unknown",
|
||||
"score": int(event.get("score") or 0),
|
||||
"ip": _truncate(event.get("ip"), 64),
|
||||
"user_id": event.get("user_id"),
|
||||
"request_method": _truncate(event.get("request_method"), 16),
|
||||
"request_path": _truncate(event.get("request_path"), 256),
|
||||
"field_name": _truncate(event.get("field_name"), 80),
|
||||
"rule": _truncate(event.get("rule"), 120),
|
||||
"matched": _truncate(event.get("matched"), 120),
|
||||
"value_preview": _truncate(event.get("value_preview"), 200),
|
||||
"created_at": event.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_ban_entry(entry: dict, *, kind: str) -> dict:
|
||||
if kind == "ip":
|
||||
return {
|
||||
"ip": _truncate(entry.get("ip"), 64),
|
||||
"reason": _truncate(entry.get("reason"), 200),
|
||||
"added_at": entry.get("added_at"),
|
||||
"expires_at": entry.get("expires_at"),
|
||||
"is_active": int(entry.get("is_active") or 0),
|
||||
}
|
||||
if kind == "user":
|
||||
return {
|
||||
"user_id": entry.get("user_id"),
|
||||
"reason": _truncate(entry.get("reason"), 200),
|
||||
"added_at": entry.get("added_at"),
|
||||
"expires_at": entry.get("expires_at"),
|
||||
"is_active": int(entry.get("is_active") or 0),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/dashboard", methods=["GET"])
|
||||
@admin_required
|
||||
def get_security_dashboard():
|
||||
"""
|
||||
获取安全仪表板数据
|
||||
返回:
|
||||
- 最近24小时威胁事件数
|
||||
- 当前封禁IP数
|
||||
- 当前封禁用户数
|
||||
- 最近10条威胁事件
|
||||
"""
|
||||
try:
|
||||
threat_24h = security_db.get_threat_events_count(hours=24)
|
||||
except Exception:
|
||||
threat_24h = 0
|
||||
|
||||
try:
|
||||
banned_ips = blacklist.get_banned_ips()
|
||||
except Exception:
|
||||
banned_ips = []
|
||||
|
||||
try:
|
||||
banned_users = blacklist.get_banned_users()
|
||||
except Exception:
|
||||
banned_users = []
|
||||
|
||||
try:
|
||||
recent = security_db.get_threat_events_list(page=1, per_page=10, filters={}).get("items", [])
|
||||
recent_items = [_sanitize_threat_event(e) for e in recent if isinstance(e, dict)]
|
||||
except Exception:
|
||||
recent_items = []
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"threat_events_24h": int(threat_24h or 0),
|
||||
"banned_ip_count": len(banned_ips),
|
||||
"banned_user_count": len(banned_users),
|
||||
"recent_threat_events": recent_items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/threats", methods=["GET"])
|
||||
@admin_required
|
||||
def get_threat_events():
|
||||
"""
|
||||
获取威胁事件列表(分页)
|
||||
参数: page, per_page, severity, event_type
|
||||
"""
|
||||
page = _parse_int_arg("page", 1, min_value=1, max_value=100000)
|
||||
per_page = _parse_int_arg("per_page", 20, min_value=1, max_value=200)
|
||||
severity = (request.args.get("severity") or "").strip()
|
||||
event_type = (request.args.get("event_type") or "").strip()
|
||||
|
||||
filters: dict[str, Any] = {}
|
||||
if severity:
|
||||
filters["severity"] = severity
|
||||
if event_type:
|
||||
filters["event_type"] = event_type
|
||||
|
||||
data = security_db.get_threat_events_list(page, per_page, filters)
|
||||
items = data.get("items") or []
|
||||
data["items"] = [_sanitize_threat_event(e) for e in items if isinstance(e, dict)]
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/banned-ips", methods=["GET"])
|
||||
@admin_required
|
||||
def get_banned_ips():
|
||||
"""获取封禁IP列表"""
|
||||
items = blacklist.get_banned_ips()
|
||||
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="ip") for x in items]})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/banned-users", methods=["GET"])
|
||||
@admin_required
|
||||
def get_banned_users():
|
||||
"""获取封禁用户列表"""
|
||||
items = blacklist.get_banned_users()
|
||||
return jsonify({"count": len(items), "items": [_sanitize_ban_entry(x, kind="user") for x in items]})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ban-ip", methods=["POST"])
|
||||
@admin_required
|
||||
def ban_ip():
|
||||
"""
|
||||
手动封禁IP
|
||||
参数: ip, reason, duration_hours(可选), permanent(可选)
|
||||
"""
|
||||
data = _parse_json()
|
||||
ip = str(data.get("ip") or "").strip()
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
duration_hours_raw = data.get("duration_hours", 24)
|
||||
permanent = _parse_bool(data.get("permanent", False))
|
||||
|
||||
if not ip:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
|
||||
ok = blacklist.ban_ip(ip, reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
return jsonify({"error": "封禁失败"}), 400
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/unban-ip", methods=["POST"])
|
||||
@admin_required
|
||||
def unban_ip():
|
||||
"""解除IP封禁"""
|
||||
data = _parse_json()
|
||||
ip = str(data.get("ip") or "").strip()
|
||||
if not ip:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
|
||||
ok = blacklist.unban_ip(ip)
|
||||
if not ok:
|
||||
return jsonify({"error": "未找到封禁记录"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ban-user", methods=["POST"])
|
||||
@admin_required
|
||||
def ban_user():
|
||||
"""手动封禁用户"""
|
||||
data = _parse_json()
|
||||
user_id_raw = data.get("user_id")
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
duration_hours_raw = data.get("duration_hours", 24)
|
||||
permanent = _parse_bool(data.get("permanent", False))
|
||||
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
if not reason:
|
||||
return jsonify({"error": "reason不能为空"}), 400
|
||||
|
||||
try:
|
||||
duration_hours = max(1, int(duration_hours_raw))
|
||||
except Exception:
|
||||
duration_hours = 24
|
||||
|
||||
ok = blacklist._ban_user_internal(user_id, reason=reason, duration_hours=duration_hours, permanent=permanent)
|
||||
if not ok:
|
||||
return jsonify({"error": "封禁失败"}), 400
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/unban-user", methods=["POST"])
|
||||
@admin_required
|
||||
def unban_user():
|
||||
"""解除用户封禁"""
|
||||
data = _parse_json()
|
||||
user_id_raw = data.get("user_id")
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except Exception:
|
||||
user_id = None
|
||||
|
||||
if user_id is None:
|
||||
return jsonify({"error": "user_id不能为空"}), 400
|
||||
|
||||
ok = blacklist.unban_user(user_id)
|
||||
if not ok:
|
||||
return jsonify({"error": "未找到封禁记录"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/ip-risk/<ip>", methods=["GET"])
|
||||
@admin_required
|
||||
def get_ip_risk(ip):
|
||||
"""获取指定IP的风险评分和历史事件"""
|
||||
ip_text = str(ip or "").strip()
|
||||
if not ip_text:
|
||||
return jsonify({"error": "ip不能为空"}), 400
|
||||
|
||||
history = security_db.get_ip_threat_history(ip_text)
|
||||
return jsonify(
|
||||
{
|
||||
"ip": _truncate(ip_text, 64),
|
||||
"risk_score": int(scorer.get_ip_score(ip_text) or 0),
|
||||
"is_banned": bool(blacklist.is_ip_banned(ip_text)),
|
||||
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/user-risk/<int:user_id>", methods=["GET"])
|
||||
@admin_required
|
||||
def get_user_risk(user_id):
|
||||
"""获取指定用户的风险评分和历史事件"""
|
||||
history = security_db.get_user_threat_history(user_id)
|
||||
return jsonify(
|
||||
{
|
||||
"user_id": int(user_id),
|
||||
"risk_score": int(scorer.get_user_score(user_id) or 0),
|
||||
"is_banned": bool(blacklist.is_user_banned(user_id)),
|
||||
"threat_history": [_sanitize_threat_event(e) for e in history if isinstance(e, dict)],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@security_bp.route("/api/admin/security/cleanup", methods=["POST"])
|
||||
@admin_required
|
||||
def cleanup_expired():
|
||||
"""清理过期的封禁记录和衰减风险分"""
|
||||
try:
|
||||
blacklist.cleanup_expired()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
scorer.decay_scores()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 可选:返回当前连接池统计信息,便于排查后台运行状态
|
||||
pool_stats = None
|
||||
try:
|
||||
pool_stats = db_pool.get_pool_stats()
|
||||
except Exception:
|
||||
pool_stats = None
|
||||
|
||||
return jsonify({"success": True, "pool_stats": pool_stats})
|
||||
|
||||
@@ -14,11 +14,20 @@ def admin_required(f):
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
logger = get_logger()
|
||||
try:
|
||||
logger = get_logger()
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
logger.debug(f"[admin_required] 检查会话,admin_id存在: {'admin_id' in session}")
|
||||
if "admin_id" not in session:
|
||||
logger.warning(f"[admin_required] 拒绝访问 {request.path} - session中无admin_id")
|
||||
is_api = request.blueprint == "admin_api" or request.path.startswith("/yuyx/api")
|
||||
is_api = (
|
||||
request.blueprint in {"admin_api", "admin_security", "admin_security_yuyx"}
|
||||
or request.path.startswith("/yuyx/api")
|
||||
or request.path.startswith("/api/admin")
|
||||
)
|
||||
if is_api:
|
||||
return jsonify({"error": "需要管理员权限"}), 403
|
||||
return redirect(url_for("pages.admin_login_page"))
|
||||
|
||||
Reference in New Issue
Block a user