Files
MeshSate/app/console.py
zhaolei f1a176aae0 fix: 裁决口径重构 — rule_overrides 改处置地板,YARA 权重分档与阈值解耦
三件事,都围绕"分数只反映证据,不放行由地板保证":

- rule_overrides 的 manual_review 旧实现是 score=max(score,65) 并跳过该规则自身权重:
  注进去的 65 不是证据,再叠任意 ≥5 分噪声就越过 block_score=70 变拒绝,同时显示分数
  与真实证据脱钩。改为步骤 9「复核地板」:权重照常累加,裁决后处置低于复核才抬到
  manual_review,已判 reject 不降回。3661 条历史扫描重放 0 收紧 0 放行;把 review_score
  下界抬到 36/40/50 或把 DEE 权重降到 10,无地板时干净 exe 静默放行,有地板恒为人工复核。
- DangerousExecutableExtension 进 rule_overrides(manual_review),落实光闸口径「EXE/DLL/
  脚本不自动放行」,不再依赖 weight=30 恰好等于 review_score 下界这种数值巧合。
- YARA 权重分档 85/65/45/25 → 85/60/30/10,只有 critical 一档能独立越过 70;
  档位与 rules/*.yar 的 meta.risk_level 一一对应,控制台注释与 policy.html 同步。
- 词表:RemoteDownloadUnix/PowerShell 移出零容忍、改判"工具与 URL/IP 同框",
  新增 RemoteDownloadExec(40)/PayloadDropToDisk(45) 带内网源站白名单。裸 cmdlet 名会把
  nvidiaInspector.exe 的 add_DownloadFileCompleted 白送 30 分,把 45 分复核顶成 75 分拒绝。
- 风险等级文案:裁决已是 malicious/error 时仍输出"打开风险低"是自相矛盾的对外文案,
  强制收敛到 critical。
- 靶场 L5-005 expected_verdict reject→review:旧 85 分靠载体45+时间戳20+未签名10+架构10
  堆叠,属权重堆叠误报;真实注入器由 C1_PE_ProcessInjection(85) 拦截。
2026-11-02 20:27:30 +08:00

1881 lines
74 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""控制台路由(P2)。
职责:
- 登录 / 登出(session cookie + CSRF + 失败锁定)
- 策略配置页面(表单+YAML 双模式 + 版本历史 + 一键回滚)
- 任务历史查询
- 引擎状态看板
- 权限分级(admin / operator / viewer)
"""
from __future__ import annotations
import hashlib
import json
import os
import secrets
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
# ============================================================================
# 内部测试 channel 黑名单
# ============================================================================
# 控制台页面(Dashboard / 任务历史)默认隐藏内部测试 / 对账脚本产生的记录。
# 这些 channel 由开发 / 运维工具在内部调用 scanner.scan_file() 时自动产生,
# 不应暴露给控制台用户。若后续新增内部脚本 channel,在此补充即可。
INTERNAL_TEST_CHANNELS: frozenset[str] = frozenset({
"test_center", # 测试靶场手动重扫
"v", # 对账脚本 v5/v7/v8
"verify", # 单条验证脚本
"diag", # 诊断脚本
"debug", # 调试脚本
"final", # 最终验证
"console", # console.py 内部调用
"console-test", # console 测试模式
"regen_v2", # 批量重建
"regenerate_index", # index.json 重建
"audit_f3", # 审计脚本
"audit", # 审计脚本
"testcases", # 生成 TEST_CASES.md
"test", # 测试
"yara_verify", # YARA 验证
"whitelist_v", # 白名单验证
"boundary", # 边界测试
"yara_v2", # YARA v2 测试
"docgen", # 文档生成脚本
})
def _apply_production_channel_filter(query, model_class):
"""给 ScanTask / ScanFinding 查询追加「隐藏内部测试 channel」过滤。
用法: _apply_production_channel_filter(
db.query(func.count(ScanTask.id)), ScanTask
).scalar()
"""
col = getattr(model_class, "channel", None)
if col is None:
return query
return query.filter(col.notin_(INTERNAL_TEST_CHANNELS))
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import (
AuditLog,
ConsoleUser,
LoginAttempt,
PolicyVersion,
ScanFinding,
ScanTask,
)
from app.security.crypto import verify_password, hash_password
from app.services.policy import PolicyStore
router = APIRouter(prefix="/console", tags=["console"])
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# =============================================================================
# Jinja2 自定义过滤器
# =============================================================================
def _bj_time(dt: Any, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""把 Postgres 里存的 UTC datetime 转成北京时间再格式化。
兼容三种输入:
1. None → 返回 ''
2. datetime 对象(ORM 查出来的)→ 直接转
3. ISO format 字符串(PolicyStore API 返回的)→ 先 fromisoformat 转
"""
if dt is None:
return ""
from datetime import datetime as _dt
if isinstance(dt, str):
# 字符串:ISO format,如 "2026-10-27T12:00:00+00:00" 或 "2026-10-27 12:00:00"
try:
dt = _dt.fromisoformat(dt.replace("Z", "+00:00"))
except Exception:
return dt # 解析不了原样返回
# naive datetime → 当作 UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# 直接 +8 小时(Asia/Shanghai 无夏令时)
bj = dt.astimezone(timezone(timedelta(hours=8)))
return bj.strftime(fmt)
templates.env.filters["bj_time"] = _bj_time
templates.env.filters["bj_date"] = lambda dt: _bj_time(dt, "%Y-%m-%d")
templates.env.filters["bj_clock"] = lambda dt: _bj_time(dt, "%H:%M:%S")
# ---------- 统一中文标签体系过滤器 ----------
from app.i18n_labels import ( # noqa: E402
action_badge, action_label, category_label, channel_label,
engine_label, mode_label, risk_badge, risk_desc, risk_label, risk_matrix,
severity_badge, severity_label, verdict_badge, verdict_label, verdict_risk,
)
templates.env.filters["cn_verdict"] = verdict_label
templates.env.filters["cn_verdict_badge"] = verdict_badge
templates.env.filters["cn_verdict_risk"] = verdict_risk
templates.env.filters["cn_action"] = action_label
templates.env.filters["cn_action_badge"] = action_badge
templates.env.filters["cn_severity"] = severity_label
templates.env.filters["cn_severity_badge"] = severity_badge
templates.env.filters["cn_engine"] = engine_label
templates.env.filters["cn_mode"] = mode_label
templates.env.filters["cn_category"] = category_label
templates.env.filters["cn_channel"] = channel_label
templates.env.filters["cn_risk"] = risk_label
templates.env.filters["cn_risk_badge"] = risk_badge
templates.env.filters["cn_risk_desc"] = risk_desc
templates.env.globals["RISK_MATRIX"] = risk_matrix()
# =============================================================================
# YAML 注释注入:给 yaml.dump 出来的裸文本自动加中文行尾注释
# 用法:yaml_text = _annotate_yaml(yaml.dump(policy.raw, ...))
# =============================================================================
def _annotate_yaml(raw: str) -> str:
"""给 yaml.dump 的输出逐行追加中文注释。只给我们认识的 key 加。"""
# -------- 字段 → 注释行 --------
# key 匹配规则:"(缩进)key:" 格式
_FIELD_HINTS = {
# ---- 顶层 ----
"version": '【str】策略版本号(自己定,如 "2026.10.2")',
"description": '【str】策略说明(给自己/运维看的描述)',
# ---- global ----
"global": '全局阈值配置',
"block_score": '【int 0~100】累计风险分 ≥ 此值 → 判定 malicious 直接拒绝',
"review_score": '【[int, int]】累计分落在左闭右开区间 → suspicious 转人工复核',
"engine_failure_mode": '【fail_closed | fail_open】引擎不可用时的降级策略(fail_closed=拒绝 fail_open=放行)',
"max_file_size": '【字节】单文件最大体积,超过直接拒绝(生产推荐 52428800 = 50MB)',
"sync_max_size": '【字节】同步扫描上限,超过自动转异步',
"allow_encrypted_archive": '【true/false】是否允许带密码的加密 ZIP/RAR/7z 压缩包',
"allow_encrypted_document": '【true/false】是否允许加密 Office 文档(.docx/.xlsx/.pptx)',
# ---- 零容忍 ----
"zero_tolerance_rules": '命中其中任意一条 → 直接 reject,不走评分。⚠️ 删一条=少一层防御!',
# ---- 扩展名 ----
"allowed_extensions": '白名单扩展名(带 . 小写)。生产必须填全,不在列表直接拒绝',
"sandbox_required_extensions": '一期无沙箱,预留字段(命中自动转异步)',
# ---- rule_overrides ----
"rule_overrides": '特定规则的处置覆盖。action: pass | manual_review | reject',
# ---- yara ----
"yara_rule_weights": 'YARA 规则按严重度分档赋权重(critical=85 high=60 medium=30 low=10)',
# ---- keyword ----
"keyword_rules": '词表引擎规则。match: contains | regex | any_of',
# ---- notification ----
"notification": '拒绝通知设置',
"include_reason": '【true/false】拒绝时是否返回详细原因给调用方',
"max_reason_length": '【int】拒绝原因最大长度(字符)',
# ---- audit ----
"audit": '审计日志设置',
"log_findings": '【true/false】是否记录扫描命中的每条 finding',
"log_extracted_paths": '【true/false】是否记录从压缩包提取的子文件路径',
# ---- 操作指引标题 ----
}
# 零容忍规则名 → 中文(行内注释)
_ZT_HINTS = {
"PdfJavaScript": "PDF 打开即执行脚本(跨平台)",
"PdfLaunchAction": "PDF 打开即启动外部程序",
"PdfOpenAction": "PDF 自动打开对象",
"PathTraversal": "解包路径穿越(../ 符号)",
"IsoWithExecutable": "ISO 挂载后内部含可执行文件",
"MacroObfuscated": "宏代码混淆/加密(意图不可知)",
"AttachedTemplate": "Office 外部模板注入(.dotm/.xltm)",
"VBAAutoExec": "VBA 宏 AutoOpen 自动执行入口",
"VBAAutoExecShell": "VBA 宏自动执行 + Shell/CreateObject 调用",
"MRaptorAutoExec": "oletools MRaptor 判定为自动执行宏",
"MRaptorAutoExecSuspicious": "MRaptor 判定为可疑自动执行宏",
"DDEAutoExecution": "DDE/DDEAUTO 自动执行字段",
"EmbeddedExecutable": "文档内嵌可执行对象(oleobj 检测)",
"LinuxExecutable": "ELF 原生二进制",
"DesktopFileAutoExec": ".desktop 文件 Exec= 字段含危险命令",
"ShellScriptDangerous": "Shell/Python/Perl 脚本含危险函数",
"LinuxPortableExecutable": "AppImage/Flatpak/Snap 便携包",
"ArchiveContainsLinuxExec": "压缩包内混有 .elf/.sh/.deb/.run/.bin",
"LnkWithAutoExec": "LNK 快捷方式含 PowerShell/cmd 自动执行",
"ChmWithScript": "CHM 帮助文件内嵌 JavaScript 或引用 .exe/.vbs",
}
# YARA 权重分档 → 中文
_WEIGHT_TIER_HINTS = {
"critical": "weight=85(严重)",
"high": "weight=60(高危)",
"medium": "weight=30(中等)",
"low": "weight=10(低危)",
}
lines = raw.split("\n")
out = []
for line in lines:
# ---- 1) 零容忍规则列表项: - RuleName(可能无缩进)----
m_zt = __import__("re").match(r'^(\s*-\s+)([A-Za-z_][A-Za-z0-9_]*)\s*$', line)
if m_zt and m_zt.group(2) in _ZT_HINTS:
out.append(f"{line} # {_ZT_HINTS[m_zt.group(2)]}")
continue
# ---- 2) YARA 权重分档头: critical: ----
m_tier = __import__("re").match(r'^(\s*)(critical|high|medium|low):\s*$', line)
if m_tier and m_tier.group(2) in _WEIGHT_TIER_HINTS:
out.append(f"{line} # {_WEIGHT_TIER_HINTS[m_tier.group(2)]}")
continue
# ---- 3) 普通 key: value ----
m_key = __import__("re").match(r'^(\s*)([a-z_][a-z0-9_]*)(:.*)$', line)
if m_key:
key = m_key.group(2)
hint = _FIELD_HINTS.get(key)
if hint and "#" not in line:
out.append(f"{line} # {hint}")
continue
out.append(line)
# 顶部加操作指引 header
header = (
"# ============================================================\n"
"# MeshSafe 全局检测策略 — 在线编辑(中文注释自动生成)\n"
"# ------------------------------------------------------------\n"
"# ✅ 保存后立即热重载生效,无需重启服务\n"
"# ⚠️ 零容忍规则动一条=少一层防御,改之前请确认\n"
"# ❌ engine_failure_mode 不要改成 fail_open(放行所有文件!)\n"
"# 💾 每次保存自动生成版本快照,可在「版本历史」回滚\n"
"# ============================================================\n"
)
return header + "\n".join(out) + "\n"
# session 存储(优先 Redis 跨 worker 共享;Redis 不可用时降级为内存 dict)
# key 格式: meshsafe:console_session:{session_id} TTL=30min
_session_store: Dict[str, Dict[str, Any]] = {}
_session_lock = threading.Lock()
_SESSION_TTL_SECONDS = 30 * 60 # 30 分钟
_MAX_FAILED_ATTEMPTS = 5
_LOCKOUT_DURATION_SECONDS = 15 * 60 # 15 分钟
_SESSION_KEY_PREFIX = "meshsafe:console_session:"
def _session_redis_set(session_id: str, data: dict) -> bool:
"""写 session 到 Redis。成功返回 True,失败返回 False。"""
try:
from app.security.deps import get_redis
r = get_redis()
key = f"{_SESSION_KEY_PREFIX}{session_id}"
# Redis hash 不能直接存 Python dict,需要把值转成字符串
# expires_at 存 float 时间戳字符串方便比较
mapping = {k: str(v) for k, v in data.items()}
r.hset(key, mapping=mapping)
r.expire(key, _SESSION_TTL_SECONDS)
return True
except Exception:
return False
def _session_redis_get(session_id: str) -> Optional[Dict[str, Any]]:
"""从 Redis 读 session。不存在或 Redis 不可用返回 None。"""
try:
from app.security.deps import get_redis
r = get_redis()
key = f"{_SESSION_KEY_PREFIX}{session_id}"
data = r.hgetall(key)
if not data:
return None
# 类型还原
result: Dict[str, Any] = {}
for k, v in data.items():
if k in ("user_id",):
result[k] = int(v)
elif k == "expires_at":
result[k] = float(v)
else:
result[k] = v
return result
except Exception:
return None
def _session_redis_delete(session_id: str) -> bool:
"""从 Redis 删 session。"""
try:
from app.security.deps import get_redis
r = get_redis()
r.delete(f"{_SESSION_KEY_PREFIX}{session_id}")
return True
except Exception:
return False
# =====================================================================
# 认证依赖
# =====================================================================
def get_current_user(request: Request) -> ConsoleUser:
"""从 session cookie 解析当前登录用户。未登录返回 None。"""
session_id = request.cookies.get("meshsafe_session")
if not session_id:
return None
# 优先 Redis,降级内存
session = _session_redis_get(session_id)
if session is None:
with _session_lock:
session = _session_store.get(session_id)
if not session:
return None
if time.time() > session.get("expires_at", 0):
_session_redis_delete(session_id)
with _session_lock:
_session_store.pop(session_id, None)
return None
db: Session = SessionLocal()
try:
user = db.get(ConsoleUser, session.get("user_id"))
if not user or not user.is_active:
return None
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
return None
return user
finally:
db.close()
def require_admin(current: ConsoleUser = Depends(get_current_user)) -> ConsoleUser:
if current is None:
raise HTTPException(status_code=401, detail="未登录")
if current.role != "admin":
raise HTTPException(status_code=403, detail="需要 admin 权限")
return current
def require_login(current: ConsoleUser = Depends(get_current_user)) -> ConsoleUser:
if current is None:
raise HTTPException(status_code=401, detail="未登录")
return current
def _log_audit(actor: str, action: str, target: str = "", detail: Optional[dict] = None) -> None:
"""写一条审计日志(带哈希链)。"""
db: Session = SessionLocal()
try:
last = db.query(AuditLog).order_by(AuditLog.id.desc()).first()
prev_hash = last.row_hash if last else ""
row_str = f"{actor}|{action}|{target}|{time.time()}"
row_hash = hashlib.sha256((prev_hash + row_str).encode()).hexdigest()
log = AuditLog(
actor=actor, actor_role="console", action=action, target=target,
outcome="success", detail=detail or {},
prev_hash=prev_hash, row_hash=row_hash,
)
db.add(log)
db.commit()
except Exception: # noqa: BLE001
pass
finally:
db.close()
# =====================================================================
# 登录 / 登出
# =====================================================================
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
async def login_page(request: Request, error: Optional[str] = None):
return templates.TemplateResponse("login.html", {"request": request, "error": error})
@router.post("/login", include_in_schema=False)
async def login_submit(
request: Request,
response: Response,
username: str = Form(...),
password: str = Form(...),
):
db: Session = SessionLocal()
try:
user = db.execute(
select(ConsoleUser).where(ConsoleUser.username == username)
).scalar_one_or_none()
# 检查锁定
if user and user.locked_until and user.locked_until > datetime.now(timezone.utc):
locked_min = int((user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": f"账号已锁定,请 {locked_min} 分钟后再试"},
status_code=401,
)
success = False
if user and verify_password(password, user.password_hash):
success = True
# 记录尝试
attempt = LoginAttempt(
username=username,
client_ip=request.client.host if request.client else "",
success=success,
)
db.add(attempt)
if not success or not user:
# 累加失败次数 + 锁定
if user:
user.failed_attempts += 1
if user.failed_attempts >= _MAX_FAILED_ATTEMPTS:
user.locked_until = datetime.now(timezone.utc) + timedelta(seconds=_LOCKOUT_DURATION_SECONDS)
user.failed_attempts = 0
db.commit()
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "用户名或密码错误"},
status_code=401,
)
# 登录成功:重置失败计数 + 发放 session
user.failed_attempts = 0
user.last_login_at = datetime.now(timezone.utc)
db.commit()
session_id = secrets.token_urlsafe(32)
expires_at = time.time() + _SESSION_TTL_SECONDS
session_data = {
"user_id": user.id,
"username": user.username,
"role": user.role,
"expires_at": expires_at,
}
_session_redis_set(session_id, session_data)
with _session_lock:
_session_store[session_id] = session_data
response = RedirectResponse(url="/console/", status_code=303)
response.set_cookie(
"meshsafe_session", session_id,
httponly=True, samesite="lax", max_age=_SESSION_TTL_SECONDS,
)
_log_audit(user.username, "login_success", "")
return response
finally:
db.close()
@router.get("/logout", include_in_schema=False)
async def logout(request: Request, current=Depends(require_login)):
session_id = request.cookies.get("meshsafe_session", "")
_session_redis_delete(session_id)
with _session_lock:
_session_store.pop(session_id, None)
response = RedirectResponse(url="/console/login", status_code=303)
response.delete_cookie("meshsafe_session")
_log_audit(current.username, "logout", "")
return response
# =====================================================================
# 控制台主页(引擎状态看板)
# =====================================================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
async def dashboard(request: Request, current=Depends(require_login)):
from app.main import policy_store, scanner
# ---------- 引擎状态中文化 ----------
ENGINE_CN = {
"structure": {"name": "文件结构分析", "layer": "L0 文件结构", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
"clamav": {"name": "ClamAV 病毒库", "layer": "L1 病毒检测", "status_ok": "正常", "status_error": "不可用",
"info_map": {"rules loaded": "规则库已加载"},
"err_map": {"getaddrinfo failed": "本地未启动 ClamAV 服务(生产部署后自动恢复)",
"ConnectionRefused": "ClamAV 连接被拒绝",
"ECONNREFUSED": "ClamAV 连接被拒绝",
"BrokenPipeError": "ClamAV 管道破裂"}},
"document": {"name": "文档深度检测", "layer": "L2 文档解析", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
"keywords": {"name": "关键词匹配", "layer": "L2 特征匹配", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
"yara": {"name": "YARA 规则匹配", "layer": "L2 规则匹配", "status_ok": "正常", "status_error": "异常",
"info_map": {"rules loaded": "规则库已加载"}},
"binary": {"name": "PE/二进制分析", "layer": "L2 二进制解析", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
}
engine_health = []
for h in [e.health() for e in scanner.engines()]:
meta = ENGINE_CN.get(h["engine"], {"name": h["engine"]})
h["display_name"] = meta.get("name", h["engine"])
h["layer_label"] = meta.get("layer", "")
h["engine_version_label"] = (h.get("version") or "") if h.get("version") not in ("builtin", "unknown") else ""
# 状态 label
if h.get("status") == "ok":
h["status_label"] = meta.get("status_ok", "正常")
elif h.get("status") == "error":
h["status_label"] = meta.get("status_error", "异常")
else:
h["status_label"] = h.get("status", "")
# info → 中文映射
raw_info = h.get("info") or ""
h["display_info"] = meta.get("info_map", {}).get(raw_info, raw_info) if raw_info else None
# error → 中文映射
raw_err = str(h.get("error") or "")
friendly_err = None
if raw_err:
for key, msg in meta.get("err_map", {}).items():
if key in raw_err:
friendly_err = msg
break
if not friendly_err:
friendly_err = "检测引擎异常(%s)" % h["engine"]
h["friendly_error"] = friendly_err
engine_health.append(h)
# ---------- 系统概览统计 ----------
policy = policy_store.current()
# YARA 规则数(扫描 rules/*.yar 统计 rule 声明)
yara_rule_count = 0
try:
from app.engines.binary import _discover_rules_dir
rules_dir = _discover_rules_dir()
if os.path.isdir(rules_dir):
for fn in sorted(os.listdir(rules_dir)):
if not fn.endswith((".yar", ".yara")):
continue
try:
with open(os.path.join(rules_dir, fn), "r", encoding="utf-8", errors="ignore") as fh:
for line in fh:
ls = line.strip()
if ls.startswith("rule ") and "{" in ls or (ls.startswith("rule ") and ls.endswith("{")):
yara_rule_count += 1
except OSError:
continue
except Exception: # noqa: BLE001
yara_rule_count = 0
# 规则总数 = 零容忍 + rule_overrides + 关键词规则 + YARA 规则
total_rules = (
len(policy.zero_tolerance_rules)
+ len(policy.rule_overrides)
+ len(policy.keyword_rules)
+ yara_rule_count
)
db: Session = SessionLocal()
try:
from sqlalchemy import func
_skip_test = ScanTask.channel.notin_(INTERNAL_TEST_CHANNELS)
total_scans = db.query(func.count(ScanTask.id)).filter(_skip_test).scalar() or 0
total_blocked = db.query(func.count(ScanTask.id)).filter(
ScanTask.action == "reject", _skip_test
).scalar() or 0
total_flagged = db.query(func.count(ScanTask.id)).filter(
ScanTask.action.in_(("reject", "manual_review", "pass_with_flag")), _skip_test
).scalar() or 0
total_findings = db.query(func.count(ScanFinding.id)).scalar() or 0
# 结论分布
verdict_rows = db.query(ScanTask.verdict, func.count(ScanTask.id)).filter(
_skip_test
).group_by(ScanTask.verdict).all()
verdict_dist = {str(v or "unknown"): int(c) for v, c in verdict_rows}
# 最近任务(8 条)
recent_tasks = db.query(ScanTask).filter(_skip_test).order_by(
ScanTask.created_at.desc()
).limit(8).all()
# 各客户端/渠道的任务数(摆渡关联性)
client_rows = db.query(
ScanTask.client_id, ScanTask.channel, func.count(ScanTask.id)
).filter(_skip_test).group_by(ScanTask.client_id, ScanTask.channel).all()
finally:
db.close()
# 结论分布 → 中文 + 顺序
VERDICT_CN = {
"clean": ("放行", "ok"),
"suspicious": ("可疑", "warn"),
"malicious": ("恶意", "bad"),
"reject": ("策略拒绝", "err"),
"error": ("异常", "err"),
"pending": ("处理中", "muted"),
}
verdict_items = []
for key in ("clean", "suspicious", "malicious", "reject", "error", "pending"):
cnt = verdict_dist.get(key, 0)
if cnt <= 0:
continue
label, cls = VERDICT_CN.get(key, (key, "muted"))
pct = (cnt / total_scans * 100) if total_scans else 0
verdict_items.append({"key": key, "label": label, "count": cnt, "pct": round(pct, 1), "cls": cls})
# 引擎在线统计
engines_ok = sum(1 for h in engine_health if h.get("ok"))
engines_total = len(engine_health)
# 渠道 TOP(关联性)
channel_rank = sorted(
[{"client_id": c or "-", "channel": ch or "-", "count": int(n)} for c, ch, n in client_rows],
key=lambda x: x["count"], reverse=True,
)[:5]
stats = {
"total_scans": total_scans,
"total_blocked": total_blocked,
"total_flagged": total_flagged,
"total_findings": total_findings,
"total_rules": total_rules,
"zero_tolerance": len(policy.zero_tolerance_rules),
"yara_rules": yara_rule_count,
"engines_ok": engines_ok,
"engines_total": engines_total,
}
# 最近任务的中文标签
for t in recent_tasks:
vlabel, vcls = VERDICT_CN.get(str(t.verdict or ""), (str(t.verdict or "-"), "muted"))
t.verdict_label = vlabel
t.verdict_cls = vcls
t.action_label = {
"pass": "放行", "pass_with_flag": "带标记放行",
"manual_review": "人工复核", "reject": "拦截", "error": "异常",
}.get(str(t.action or ""), str(t.action or "-"))
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"user": current,
"engine_health": engine_health,
"recent_tasks": recent_tasks,
"policy_store": policy_store,
"stats": stats,
"verdict_items": verdict_items,
"channel_rank": channel_rank,
},
)
# =====================================================================
# 策略配置页面(P2c)
# =====================================================================
@router.get("/policy", response_class=HTMLResponse, include_in_schema=False)
async def policy_page(request: Request, current=Depends(require_login)):
"""策略配置页面——表单模式 + YAML 模式双切换 + YARA 规则库 Tab。"""
from app.main import policy_store
import yaml
policy = policy_store.current()
versions = policy_store.list_versions()
yaml_raw = yaml.dump(policy.raw, allow_unicode=True, sort_keys=False, default_flow_style=False)
yaml_text = _annotate_yaml(yaml_raw)
# ---- 中文映射表 ----
ZERO_TOLERANT_CN = {
# 跨平台 7
"PdfJavaScript": ("PDF 嵌入 JavaScript", "PDF 文件中包含可执行 JavaScript"),
"PdfLaunchAction": ("PDF 启动外部程序", "PDF 试图通过 /Launch 调用系统程序"),
"PdfOpenAction": ("PDF 自动打开对象", "PDF 含有 /OpenAction 自动执行对象"),
"PathTraversal": ("路径穿越攻击", "压缩包内文件含 ../ 等路径穿越符号"),
"IsoWithExecutable": ("ISO 含可执行文件", "ISO 9660 镜像中带有 .exe/.dll/.elf"),
"MacroObfuscated": ("宏代码混淆", "Office VBA 宏使用字符串拼接或 Chrb 等混淆手法"),
"AttachedTemplate": ("外部模板引用", "OOXML rels 指向外部 .dotm/.xltm 模板"),
# 宏 6
"VBAAutoExec": ("VBA 自动执行", "检测到 AutoOpen/AutoExec/Document_Open 自动执行宏"),
"VBAAutoExecShell": ("VBA Shell 命令", "VBA 宏通过 Shell() 调用 cmd/powershell"),
"MRaptorAutoExec": ("宏自动执行(MRaptor)", "oletools MRaptor 判定为高风险自动执行宏"),
"MRaptorAutoExecSuspicious": ("宏可疑行为(MRaptor)", "MRaptor 发现宏含网络下载/写文件等可疑行为"),
"DDEAutoExecution": ("DDE 自动执行", "Office 文档通过 DDE 协议执行外部命令"),
"EmbeddedExecutable": ("内嵌可执行文件", "文档内部嵌入了 PE/ELF 二进制文件(oleobj 检测)"),
# Linux 5
"LinuxExecutable": ("Linux ELF 可执行文件", "文件头匹配 ELF Magic 7F 45 4C 46"),
"DesktopFileAutoExec": (".desktop 自动执行", "GNU/Linux .desktop 文件含 Exec 自动运行命令"),
"ShellScriptDangerous": ("Shell 脚本危险命令", "Shell 脚本含反弹 Shell/curl|bash 等高危命令"),
"LinuxPortableExecutable": ("Linux 下 PE 文件", "Linux 平台出现 PE .exe/.dll(攻击载体)"),
"ArchiveContainsLinuxExec": ("压缩包含 Linux ELF", "压缩包内带有 .elf 或无扩展名的 ELF"),
# Windows 纵深 2
"LnkWithAutoExec": ("LNK 快捷方式自动执行", "Windows 快捷方式含 PowerShell/cmd 自动执行"),
"ChmWithScript": ("CHM 帮助含脚本", "CHM 帮助文件内嵌 JavaScript 或引用 .exe/.vbs"),
}
YARA_CATEGORY_CN = {
"C1": "PE 注入与下载",
"C3": "Webshell 后门",
"C4": "PE Shell/壳特征",
"C5": "PE LOLBin",
"C6": "勒索软件",
"C7": "Linux 后门与持久化",
"C8": "勒索与挖矿",
"C9": "通用特征",
}
RISK_LEVEL_CN = {
"critical": ("严重", "chip-zt"),
"high": ("高危", "chip-warn"),
"medium": ("中等", "chip-info"),
"low": ("低危", "chip-ok"),
}
# 零容忍规则 → [(rule_name, cn_name, cn_desc), ...]
zt_rules_cn = []
for r in policy.zero_tolerance_rules:
cn = ZERO_TOLERANT_CN.get(r, (r, r))
zt_rules_cn.append({"rule": r, "cn_name": cn[0], "cn_desc": cn[1]})
# ---- YARA 规则库信息 ----
yara_rules_info = []
yara_loaded_count = 0
yara_files_count = 0
yara_rules_dir_display = ""
try:
import os
# 用 binary.py 的自动探测逻辑(优先环境变量 → /app/rules → rules → ./rules → ../rules)
from app.engines.binary import _discover_rules_dir
import yara as yr
rules_dir = _discover_rules_dir()
yara_rules_dir_display = rules_dir
rule_files = sorted([f for f in os.listdir(rules_dir) if f.endswith(".yar")]) if os.path.isdir(rules_dir) else []
yara_files_count = len(rule_files)
compiled = yr.compile(filepaths={f: os.path.join(rules_dir, f) for f in rule_files}) if rule_files else None
if compiled:
for r in compiled:
# yara.Rule 对象:identifier=规则名,meta=dict-like
rule_name = getattr(r, "identifier", getattr(r, "name", "unknown"))
meta = dict(getattr(r, "meta", {}) or {})
tier = policy.yara_weights.get(rule_name, None)
cat = str(meta.get("category", "-"))
rl_raw = str(meta.get("risk_level", "-"))
rl_cn, rl_chip = RISK_LEVEL_CN.get(rl_raw, (rl_raw, "chip-info"))
yara_rules_info.append({
"name": rule_name,
"category": cat,
"category_cn": YARA_CATEGORY_CN.get(cat, cat),
"risk_level": rl_raw,
"risk_level_cn": rl_cn,
"risk_level_chip": rl_chip,
"false_positive_rate": str(meta.get("false_positive_rate", "-")),
"reference": str(meta.get("reference", "-")),
"weight": f"{tier:.0f}" if tier is not None else "50",
"trigger_count": 0,
})
yara_loaded_count = len(yara_rules_info)
except Exception:
pass # yara-python 未安装或规则目录不存在,静默降级
# ---- DB 中 YARA 规则触发统计(最近 24h) ----
yara_trigger_stats: Dict[str, int] = {}
try:
from app.models import ScanFinding
from sqlalchemy import func
db = SessionLocal()
try:
rows = (
db.query(ScanFinding.rule, func.count(ScanFinding.id))
.filter(ScanFinding.engine == "yara")
.group_by(ScanFinding.rule)
.all()
)
yara_trigger_stats = {row[0]: row[1] for row in rows}
finally:
db.close()
except Exception:
pass
# 把触发次数合并到规则信息
for ri in yara_rules_info:
ri["trigger_count"] = yara_trigger_stats.get(ri["name"], 0)
return templates.TemplateResponse(
"policy.html",
{
"request": request,
"user": current,
"policy": policy,
"yaml_text": yaml_text,
"versions": versions,
"zt_rules_cn": zt_rules_cn,
"yara_rules": yara_rules_info,
"yara_loaded_count": yara_loaded_count,
"yara_files_count": yara_files_count,
"yara_rules_dir": yara_rules_dir_display,
},
)
@router.post("/policy/save", include_in_schema=False)
async def policy_save(
request: Request,
mode: str = Form(...), # "form" | "yaml"
yaml_content: str = Form(""),
current=Depends(require_admin),
):
"""保存策略。admin 权限。YAML 模式直接保存;表单模式暂在前端组装后提交 YAML。"""
from app.main import policy_store
try:
import yaml as _yaml
parsed = _yaml.safe_load(yaml_content)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=f"YAML 解析失败: {exc}")
try:
policy_store.save_from_dict(parsed, actor=current.username)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
_log_audit(current.username, "policy_save", "", {"mode": mode})
return RedirectResponse(url="/console/policy", status_code=303)
@router.post("/policy/rollback/{version}", include_in_schema=False)
async def policy_rollback(version: str, request: Request, current=Depends(require_admin)):
from app.main import policy_store
policy_store.rollback_to(version, actor=current.username)
_log_audit(current.username, "policy_rollback", version)
return RedirectResponse(url="/console/policy", status_code=303)
# =====================================================================
# 任务历史
# =====================================================================
@router.get("/tasks", response_class=HTMLResponse, include_in_schema=False)
async def tasks_page(request: Request, current=Depends(require_login)):
"""任务历史 —— 每页 50 条分页。"""
from sqlalchemy import func
PAGE_SIZE = 50
try:
page = max(1, int(request.query_params.get("page", "1")))
except (TypeError, ValueError):
page = 1
db: Session = SessionLocal()
try:
_skip_test = ScanTask.channel.notin_(INTERNAL_TEST_CHANNELS)
total = db.query(func.count(ScanTask.id)).filter(_skip_test).scalar() or 0
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
# 越界时回退到最后一页
if page > total_pages:
page = total_pages
offset = (page - 1) * PAGE_SIZE
tasks = (
db.query(ScanTask)
.filter(_skip_test)
.order_by(ScanTask.created_at.desc())
.limit(PAGE_SIZE)
.offset(offset)
.all()
)
finally:
db.close()
# 分页导航页码(当前页前后各 2 页)
lo = max(1, page - 2)
hi = min(total_pages, page + 2)
page_nums = list(range(lo, hi + 1))
return templates.TemplateResponse(
"tasks.html",
{
"request": request,
"user": current,
"tasks": tasks,
"page": page,
"page_size": PAGE_SIZE,
"total": total,
"total_pages": total_pages,
"page_nums": page_nums,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1,
"next_page": page + 1,
},
)
# =============================================================================
# 测试靶场页面
# =============================================================================
@router.get("/test", response_class=HTMLResponse, include_in_schema=False)
async def test_page(request: Request, current=Depends(require_login)):
"""测试靶场页面。
- 默认 GET → **秒开**:从 DB 查上次 channel='test_center' 的扫描结果
- ?rescan=1 → **手动重扫**:遍历样本目录调 scanner.scan_file(自动写 DB)
"""
from pathlib import Path
import json, time
from app.main import policy_store, scanner as _scanner
from app.models import ScanTask, ScanFinding # noqa: F401 (relationship 懒加载需要)
samples_dir = Path(__file__).resolve().parent.parent / "tests" / "samples"
index_path = samples_dir / "index.json"
labels_path = Path(__file__).resolve().parent.parent / "rules" / "rule_labels.yaml"
# 加载规则中文标签
rule_labels: dict[str, str] = {}
layer_definitions: dict[str, str] = {}
try:
import yaml as _yaml
if labels_path.exists():
_lbl_raw = _yaml.safe_load(labels_path.read_text(encoding="utf-8")) or {}
# 映射 engine_rule_name → 中文标签
for k, v in _lbl_raw.items():
if isinstance(v, dict) and "label" in v:
rule_labels[k] = v["label"]
# 层定义
if index_path.exists():
_idx = json.loads(index_path.read_text(encoding="utf-8"))
layer_definitions = _idx.get("layer_definition", {})
except Exception: # noqa: BLE001
pass
rescan = request.query_params.get("rescan") == "1"
samples_list: list[Path] = []
meta_map: dict[str, dict] = {}
policy_version = policy_store.current().version
last_run_at = None
if index_path.exists():
index = json.loads(index_path.read_text(encoding="utf-8"))
meta_map = {s["filename"]: s for s in index.get("samples", [])}
# ★ 只展示 index.json 里登记的样本(不遍历目录,避免临时/调试文件混入)
# 每个样本必须: index.json 有登记 + 磁盘上确实存在
for s in index.get("samples", []):
fname = s["filename"]
fpath = samples_dir / fname
if fpath.is_file():
samples_list.append(fpath)
results: list[dict] = []
pass_cnt = fail_cnt = 0
# ========== verdict 三段式 tier 映射 ==========
# tier 0 = 安全(clean/pass)
# tier 1 = 可疑(suspicious/review)—— 提示级别,放行也可接受
# tier 2 = 危险(reject/malicious/error)—— 必须拦截
# 对账匹配规则(符合 CSV 原始预期语义):
# 预期 0 → 实际 0 或 1 都 OK(可疑只是提示)
# 预期 1 → 实际 1 或 2 都 OK(更严也算通过)
# 预期 2 → 实际必须 2(不能降级放行)
def _verdict_tier(v: str) -> int:
v = (v or "").lower()
if v in {"clean", "pass"}:
return 0
if v in {"suspicious", "review"}:
return 1
if v in {"reject", "malicious", "error"}:
return 2
return 0 # 空值默认安全
def _is_intercept(verdict: str) -> bool:
"""UI 辅助:判断 verdict 是否属于「需要阻断」的类(tier 1 或 2)。"""
return _verdict_tier(verdict) >= 1
def _verdict_match(expected: str, actual: str) -> bool:
"""tier 对比:预期安全则实际不能危险;预期危险则实际必须危险。"""
if not expected:
return True
exp = _verdict_tier(expected)
act = _verdict_tier(actual)
if exp == 0:
return act <= 1 # 期望安全 → 安全或可疑都行
if exp == 1:
return act >= 1 # 期望可疑 → 可疑或危险都行
return act == 2 # exp == 2 → 必须危险
# ========== 分支 A:手动重扫 ==========
if rescan and samples_list:
db = SessionLocal()
try:
import asyncio as _asyncio
_loop = _asyncio.get_running_loop()
for fpath in samples_list:
meta = meta_map.get(fpath.name, {})
t0 = time.perf_counter()
outcome = await _loop.run_in_executor(
None,
lambda fp=fpath: _scanner.scan_file(
file_path=str(fp),
original_filename=fp.name,
task_id=f"console-test-{fp.name}-{int(time.time() * 1000)}",
channel="test_center",
client_id="console",
),
)
ms = int((time.perf_counter() - t0) * 1000)
findings = [
{
"engine": fi.engine,
"rule": fi.rule,
"severity": fi.severity,
"weight": fi.weight,
"detail": fi.detail[:200],
}
for fi in outcome.findings
]
verdict = outcome.verdict
expected_verdict = meta.get("expected_verdict", "")
expected_rules = set(meta.get("expected_rules", []))
actual_rules = set(f["rule"] for f in findings)
verdict_match = _verdict_match(expected_verdict, verdict)
# rule_hit: 有 expected_rules 时看交集,无 expected_rules 时只要 verdict 对就算对
rule_hit = (
bool(expected_rules & actual_rules)
if expected_rules
else True
)
clamav_error = any(
"clamav" in str(e).lower()
for e in outcome.meta.get("engine_errors", [])
)
overall_ok = verdict_match and rule_hit
verdict_label = (
f"error (ClamAV 不可用)"
if (verdict == "error" and clamav_error and not expected_intercept)
else verdict
)
results.append({
"filename": fpath.name,
"case_id": meta.get("case_id", ""),
"category": meta.get("category", "未知"),
"description": meta.get("description", ""),
"size": fpath.stat().st_size,
"verdict": verdict_label,
"actual_verdict": verdict,
"expected_verdict": expected_verdict,
"score": outcome.score,
"duration_ms": ms,
"findings_count": len(findings),
"findings": findings,
"expected_rules": sorted(expected_rules),
"actual_rules": sorted(actual_rules),
"verdict_match": verdict_match,
"rule_hit": rule_hit,
"overall_ok": overall_ok,
"clamav_error": clamav_error,
"engine_errors": outcome.meta.get("engine_errors", []),
})
pass_cnt = sum(1 for r in results if r["overall_ok"])
fail_cnt = len(results) - pass_cnt
# 取这批写入的最新时间
if results:
from sqlalchemy import func
newest = (
db.query(func.max(ScanTask.created_at))
.filter(ScanTask.channel == "test_center")
.scalar()
)
last_run_at = newest
finally:
db.close()
# ========== 分支 B:秒开 —— 从 DB 读取 ==========
elif samples_list:
db = SessionLocal()
try:
filenames = [f.name for f in samples_list]
# 子查询:每个 filename 取最新 id
from sqlalchemy import desc
# 直接查:channel=test_center AND filename IN (...),按 filename + created_at 排序,
# 应用层去重取最新
tasks = (
db.query(ScanTask)
.filter(
ScanTask.channel == "test_center",
ScanTask.filename.in_(filenames),
)
.order_by(ScanTask.filename, desc(ScanTask.created_at))
.all()
)
# 每个 filename 取第一条(最新)
latest_map: dict[str, ScanTask] = {}
for t in tasks:
if t.filename not in latest_map:
latest_map[t.filename] = t
last_run_at = (
max((t.created_at for t in latest_map.values()), default=None)
or (db.query(ScanTask.created_at)
.filter(ScanTask.channel == "test_center")
.order_by(desc(ScanTask.created_at))
.first() or (None,))[0]
)
for fpath in samples_list:
meta = meta_map.get(fpath.name, {})
task = latest_map.get(fpath.name)
expected_verdict = meta.get("expected_verdict", "")
expected_rules = set(meta.get("expected_rules", []))
if task is None:
# DB 还没这条记录 —— 标记为"从未测试"
results.append({
"filename": fpath.name,
"case_id": meta.get("case_id", ""),
"category": meta.get("category", "未知"),
"description": meta.get("description", ""),
"size": fpath.stat().st_size,
"verdict": "pending",
"actual_verdict": "pending",
"expected_verdict": expected_verdict,
"score": 0,
"duration_ms": 0,
"findings_count": 0,
"findings": [],
"expected_rules": sorted(expected_rules),
"actual_rules": [],
"verdict_match": False,
"rule_hit": False,
"overall_ok": False,
"clamav_error": False,
"engine_errors": [],
"never_run": True,
})
continue
# 用 DB 里的 ScanTask + ScanFindings 组装
findings = [
{
"engine": fi.engine,
"rule": fi.rule,
"severity": fi.severity,
"weight": fi.weight,
"detail": fi.detail[:200],
}
for fi in task.findings
]
verdict = task.verdict
actual_rules = set(f["rule"] for f in findings)
verdict_match = _verdict_match(expected_verdict, verdict)
rule_hit = (
bool(expected_rules & actual_rules)
if expected_rules
else True
)
clamav_error = any(
"clamav" in str(e).lower()
for e in (task.detail.get("engine_errors", []) if task.detail else [])
)
overall_ok = verdict_match and rule_hit
verdict_label = (
f"error (ClamAV 不可用)"
if (verdict == "error" and clamav_error and _is_intercept(expected_verdict))
else verdict
)
results.append({
"filename": fpath.name,
"case_id": meta.get("case_id", ""),
"category": meta.get("category", "未知"),
"description": meta.get("description", ""),
"size": task.size or fpath.stat().st_size,
"verdict": verdict_label,
"actual_verdict": verdict,
"expected_verdict": expected_verdict,
"score": task.score or 0,
"duration_ms": task.duration_ms or 0,
"findings_count": len(findings),
"findings": findings,
"expected_rules": sorted(expected_rules),
"actual_rules": sorted(actual_rules),
"verdict_match": verdict_match,
"rule_hit": rule_hit,
"overall_ok": overall_ok,
"clamav_error": clamav_error,
"engine_errors": task.detail.get("engine_errors", []) if task.detail else [],
"task_policy_version": task.policy_version,
})
pass_cnt = sum(1 for r in results if r["overall_ok"])
fail_cnt = len(results) - pass_cnt
finally:
db.close()
return templates.TemplateResponse(
"test_center.html",
{
"request": request,
"user": current,
"results": results,
"total": len(results),
"pass_count": pass_cnt,
"fail_count": fail_cnt,
"samples_dir": str(samples_dir),
"policy_version": policy_version,
"last_run_at": last_run_at,
"rescan": rescan,
"rule_labels": rule_labels,
"layer_definitions": layer_definitions,
},
)
# =============================================================================
# 测试靶场 —— 自定义文件上传扫描
# =============================================================================
@router.post("/test/upload", include_in_schema=False)
async def test_upload(
request: Request,
files: List[UploadFile] = File(...),
current=Depends(require_login),
):
"""接收用户上传的自定义文件,即时扫描,返回 JSON 结果。
支持单文件或多文件批量上传。临时文件扫描后自动清理。
"""
import os
import tempfile
import uuid
from app.main import scanner # 应用级单例 scanner
results: List[Dict[str, Any]] = []
for f in files:
filename = f.filename or "unnamed"
suffix = os.path.splitext(filename)[1] or ""
task_id = f"console-upload-{uuid.uuid4().hex[:12]}"
# 1) 保存到临时文件
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await f.read()
if not content:
# 空文件不是错误 — 合法文件, 只是没内容, 直接放行
results.append({
"filename": filename,
"size": 0,
"verdict": "pass",
"verdict_cn": verdict_label("pass"),
"verdict_badge": verdict_badge("pass"),
"score": 0,
"reason": "空文件,无风险",
"findings": [],
"duration_ms": 0,
"ok": True,
})
continue
tmp.write(content)
tmp_path = tmp.name
except Exception as exc: # noqa: BLE001
results.append({
"filename": filename,
"size": 0,
"verdict": "error",
"verdict_cn": verdict_label("error"),
"verdict_badge": verdict_badge("error"),
"score": 0,
"reason": f"文件接收失败: {exc}",
"findings": [],
"duration_ms": 0,
"ok": False,
})
continue
# 2) 扫描(放线程池,不阻塞 event loop)
try:
import asyncio as _asyncio
_loop = _asyncio.get_running_loop()
outcome = await _loop.run_in_executor(
None,
lambda: scanner.scan_file(
file_path=tmp_path,
original_filename=filename,
task_id=task_id,
channel="console_upload",
client_id=current.username,
),
)
findings_list = []
for fi in outcome.findings:
findings_list.append({
"engine": fi.engine,
"rule": fi.rule,
"severity": fi.severity,
"weight": fi.weight,
"detail": fi.detail[:300],
})
results.append({
"filename": filename,
"size": outcome.file_info.get("size", 0),
"verdict": outcome.verdict,
"verdict_cn": verdict_label(outcome.verdict),
"verdict_badge": verdict_badge(outcome.verdict),
"action": outcome.action,
"score": outcome.score,
"reason": outcome.reason,
"findings": findings_list,
"duration_ms": outcome.duration_ms,
"ok": outcome.verdict != "error",
"clamav_error": any(
"clamav" in str(e).lower()
for e in outcome.meta.get("engine_errors", [])
),
"engine_errors": outcome.meta.get("engine_errors", []),
"sha256": outcome.file_info.get("sha256", ""),
})
finally:
# 3) 清理临时文件
try:
os.unlink(tmp_path)
except OSError:
pass
return JSONResponse({"results": results})
# =============================================================================
# ClamAV 病毒库管理
# =============================================================================
CLAMAV_DB_DIR = "/var/lib/clamav"
CLAMAV_ALLOWED_EXTS = {".cvd", ".cld", ".ign2"}
CLAMAV_MAX_UPLOAD = 300 * 1024 * 1024 # 300MB 总大小
# 允许覆盖的签名库文件名(clamd 定期 SelfCheck 会自动热加载)
CLAMAV_DB_FILES = {
"main.cvd", "main.cvd.sign",
"daily.cvd", "daily.cvd.sign",
"bytecode.cvd", "bytecode.cvd.sign",
# 增量 diff 文件(可选)
"daily.ndb", "daily.ndb.sign",
}
@router.get("/clamav/info", include_in_schema=False)
async def clamav_info(current=Depends(require_login)):
"""返回 ClamAV 病毒库详情(给前端按钮点击后刷新用)。"""
import socket as _socket
result: Dict[str, Any] = {
"daemon_ok": False,
"daemon_version": "",
"signature_date": "",
"signature_days_old": None,
"files": [],
}
# 1) clamd 连通性 + 版本
try:
from app.config import get_settings
s = get_settings()
with _socket.create_connection((s.clamav_host, s.clamav_port), timeout=5) as sock:
sock.sendall(b"zPING\0")
pong = sock.recv(100).rstrip(b"\0").decode()
result["daemon_ok"] = (pong.upper() == "PONG")
with _socket.create_connection((s.clamav_host, s.clamav_port), timeout=5) as sock:
sock.sendall(b"zVERSION\0")
ver = sock.recv(500).rstrip(b"\0").decode()
result["daemon_version"] = ver
# 解析签名库日期:ClamAV 1.5.4/28166/Tue Oct 27 14:24:12 2026
parts = ver.split("/")
if len(parts) >= 3:
date_str = parts[-1].strip()
try:
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
sig_dt = _dt.strptime(date_str, "%a %b %d %H:%M:%S %Y").replace(tzinfo=_tz.utc)
bj = sig_dt.astimezone(_tz(_td(hours=8)))
result["signature_date"] = bj.strftime("%Y-%m-%d %H:%M:%S")
result["signature_days_old"] = max(0, int((datetime.now(timezone.utc) - sig_dt).total_seconds() / 86400))
except ValueError:
pass
except Exception as exc: # noqa: BLE001
result["daemon_error"] = f"{type(exc).__name__}: {exc}"
# 2) 签名库目录文件列表
try:
for fname in sorted(os.listdir(CLAMAV_DB_DIR)):
fpath = os.path.join(CLAMAV_DB_DIR, fname)
if os.path.isfile(fpath) and (fname.endswith((".cvd", ".cld", ".ign2")) or fname.startswith("daily")):
size = os.path.getsize(fpath)
mtime = datetime.fromtimestamp(os.path.getmtime(fpath), tz=timezone.utc)
result["files"].append({
"name": fname,
"size": size,
"size_mb": round(size / 1024 / 1024, 1),
"mtime": _bj_time(mtime),
})
except Exception as exc: # noqa: BLE001
result["files_error"] = str(exc)
return JSONResponse(result)
@router.post("/clamav/update", include_in_schema=False)
async def clamav_update(current=Depends(require_login)):
"""尝试在线更新病毒库(调用 freshclam)。
光闸内网环境大概率拉不到(断网隔离),会返回友好提示请使用离线上传。
"""
import subprocess
_log_audit(current.username, "clamav_update_online", "")
try:
proc = subprocess.run(
["freshclam", "--stdout"],
capture_output=True, text=True, timeout=120,
)
output = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode == 0 and "OK" in output.upper():
return JSONResponse({
"ok": True,
"message": "病毒库在线更新成功",
"output": output[-500:],
})
# freshclam 失败(光闸内网常态)
return JSONResponse({
"ok": False,
"message": "在线更新失败(光闸内网无法联网),请使用「上传签名库」离线导入",
"output": output[-500:],
}, status_code=200) # 200:业务失败但不是服务异常
except FileNotFoundError:
return JSONResponse({
"ok": False,
"message": "freshclam 命令不可用,请使用「上传签名库」离线导入",
}, status_code=200)
except subprocess.TimeoutExpired:
return JSONResponse({
"ok": False,
"message": "freshclam 超时(光闸内网常态),请使用「上传签名库」离线导入",
}, status_code=200)
@router.post("/clamav/upload", include_in_schema=False)
async def clamav_upload(
request: Request,
files: List[UploadFile] = File(...),
current=Depends(require_login),
):
"""离线上传 ClamAV 签名库文件(main.cvd / daily.cvd / bytecode.cvd 等)。
保存到 /var/lib/clamav/(与 clamav 容器共享 bind mount)。
clamd 每 600 秒 SelfCheck 会自动热加载新签名,不重启也生效。
"""
import hashlib
# ---- 先一次性读完所有内容(UploadFile.read() 后 seek(0) 在某些场景不生效)----
file_contents: List[tuple] = [] # [(filename, content_bytes), ...]
total_size = 0
for f in files:
content = await f.read()
name = os.path.basename(f.filename or "")
file_contents.append((name, content))
total_size += len(content)
if total_size > CLAMAV_MAX_UPLOAD:
raise HTTPException(status_code=413, detail=f"上传总大小 {total_size/1024/1024:.0f}MB 超过上限 300MB")
saved: List[Dict[str, Any]] = []
skipped: List[str] = []
errors: List[str] = []
for filename, content in file_contents:
if not filename:
skipped.append("空文件名")
continue
# 1) 扩展名校验
ext = os.path.splitext(filename)[1].lower()
if ext not in CLAMAV_ALLOWED_EXTS:
errors.append(f"{filename}: 扩展名 {ext} 不被允许(仅接受 .cvd / .cld / .ign2)")
continue
# 2) 安全:拒绝带路径穿越的文件名
if "/" in filename or "\\" in filename or ".." in filename:
errors.append(f"{filename}: 非法文件名")
continue
# 3) 空文件
if not content:
skipped.append(f"{filename}: 空文件")
continue
# 4) 保存
dest = os.path.join(CLAMAV_DB_DIR, filename)
try:
# 备份旧文件(如有)
if os.path.exists(dest):
bak = dest + ".bak"
try:
os.replace(dest, bak)
except OSError:
pass
with open(dest, "wb") as out:
out.write(content)
sha = hashlib.sha256(content).hexdigest()
size_mb = round(len(content) / 1024 / 1024, 1)
saved.append({
"name": filename,
"size_mb": size_mb,
"sha256": sha[:16] + "...",
})
except OSError as exc:
errors.append(f"{filename}: 保存失败 - {exc}")
_log_audit(current.username, "clamav_upload", "", {"saved": len(saved), "files": [s["name"] for s in saved]})
# 5) 发 SIGHUP 给 clamd 触发立即 reload(尝试,失败不影响)
reload_msg = "clamd 每 600 秒自动热加载新签名"
try:
# 通过 clamd 自己的 RELOADSCAN 命令或 SIGHUP
import signal as _signal
import subprocess as _sp
proc = _sp.run(["sh", "-c", "kill -HUP $(pidof clamd) 2>/dev/null; echo ok"],
capture_output=True, text=True, timeout=5)
if proc.returncode == 0:
reload_msg = "已发送 SIGHUP 给 clamd,签名库立即生效"
except Exception:
pass
return JSONResponse({
"ok": len(saved) > 0,
"saved": saved,
"skipped": skipped,
"errors": errors,
"reload_message": reload_msg,
"message": f"成功导入 {len(saved)} 个签名文件,{reload_msg}",
})
# =====================================================================
# 诊断套件 — 页面路由
# =====================================================================
@router.get("/diagnostics", response_class=HTMLResponse, include_in_schema=False)
async def diagnostics_page(request: Request, current=Depends(require_login)):
"""诊断面板页面(登录后访问)。"""
from app.diagnostic.scheduler import get_diag_state
from app.diagnostic.docker_probe import status_message
from app.diagnostic.config import get_diag_config
cfg = get_diag_config()
state = get_diag_state()
docker_msg = status_message(state.get("docker_status", "none"))
# 页面必跑的轻量规则
page_rules: List[Dict[str, Any]] = []
cached_rules: List[Dict[str, Any]] = []
if cfg.enabled:
try:
from app.main import scanner, policy_store
from app.diagnostic.engine import DiagnosticEngine
db = SessionLocal()
try:
eng = DiagnosticEngine(
db=db, scanner=scanner, policy_store=policy_store,
docker_status=state.get("docker_status", "none"),
)
page_results = eng.run_page_request_rules()
page_rules = [r.to_dict() for r in page_results]
finally:
db.close()
except Exception as e: # noqa: BLE001
page_rules = [{"id": "ERR", "severity": "info",
"message": f"诊断引擎异常: {e}",
"run_at": datetime.now(timezone.utc).isoformat()}]
# 缓存规则(来自定时器)
cache = state.get("rules_cache", {})
for rid, entry in cache.items():
r = entry.get("result")
if r and isinstance(r, dict):
cached_rules.append(r)
elif r:
cached_rules.append(r.to_dict() if hasattr(r, "to_dict") else r)
# 合并所有规则(去重,page 优先覆盖 cache)
merged: Dict[str, Dict[str, Any]] = {}
for r in cached_rules:
merged[r.get("id", "")] = r
for r in page_rules:
merged[r.get("id", "")] = r
all_rules = sorted(merged.values(), key=lambda x: x.get("id", ""))
# 引擎健康(直接从 scanner.health() 拿)
engine_health: Dict[str, Any] = {}
try:
from app.main import scanner as _scanner
engine_health = _scanner.health()
except Exception:
pass
# 容器状态(来自 Docker + 持久化重启事件)
docker_containers: List[Dict[str, Any]] = []
# 先读持久化重启事件,按容器聚合计数
restart_events_by_container: Dict[str, int] = {}
try:
from app.diagnostic.storage import load_restart_events
events = load_restart_events(days=7)
for ev in events:
c = ev.get("container", "")
if c:
restart_events_by_container[c] = restart_events_by_container.get(c, 0) + 1
except Exception:
pass
try:
from app.diagnostic.docker_probe import get_client
from datetime import timezone as _tz, timedelta as _td
dc = get_client()
if dc:
now = datetime.now(_tz.utc)
for c in dc.containers.list(all=True):
attrs = c.attrs or {}
state_d = attrs.get("State", {})
started_raw = state_d.get("StartedAt", "")
uptime = "未知"
if started_raw:
try:
s = started_raw.replace("Z", "+00:00")
if "." in s:
head, frac = s.split(".", 1)
tz_idx = frac.rfind("+")
if tz_idx >= 0:
frac_ns = frac[:tz_idx]
tz = frac[tz_idx:]
frac_ns = (frac_ns + "000000")[:6]
s = head + "." + frac_ns + tz
st = datetime.fromisoformat(s)
secs = (now - st).total_seconds()
if secs >= 0:
days, rem = divmod(int(secs), 86400)
hours, rem = divmod(rem, 3600)
mins, _ = divmod(rem, 60)
if days > 0:
uptime = f"{days}d {hours}h"
elif hours > 0:
uptime = f"{hours}h {mins}m"
else:
uptime = f"{mins}m"
except Exception:
pass
docker_containers.append({
"name": c.name,
"status": state_d.get("Status", "unknown"),
"health": state_d.get("Health", {}).get("Status", ""),
"restart_count": attrs.get("RestartCount", 0),
"historical_restart_count": restart_events_by_container.get(c.name, 0),
"oom_killed": state_d.get("OOMKilled", False),
"uptime": uptime,
})
except Exception:
pass
return templates.TemplateResponse(
"diagnostics.html",
{
"request": request,
"user": current,
"nav_active": "diagnostics",
"diag_enabled": cfg.enabled,
"docker_status": state.get("docker_status", "none"),
"docker_status_msg": docker_msg,
"docker_info": state.get("docker_info", {}),
"started_at": state.get("started_at", ""),
"page_rules": page_rules,
"cached_rules": cached_rules,
"all_rules": all_rules,
"engine_health": engine_health,
"docker_containers": docker_containers,
"refresh_time": datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S"),
"badge_for": lambda sev: _diag_badge_for(sev),
"rule_count": len(all_rules),
"ok_count": sum(1 for r in all_rules if r.get("severity") == "ok"),
"warn_count": sum(1 for r in all_rules if r.get("severity") in ("medium", "low")),
"crit_count": sum(1 for r in all_rules if r.get("severity") == "high"),
},
)
def _diag_badge_for(severity: str) -> str:
"""规则 severity → 徽章 CSS 类。"""
return {
"high": "tag bad",
"medium": "tag warn",
"low": "tag notice",
"info": "tag muted",
"ok": "tag ok",
}.get(severity, "tag plain")
# =====================================================================
# 关于页 — 产品介绍(纯静态,不查数据库)
# =====================================================================
@router.get("/about", response_class=HTMLResponse, include_in_schema=False)
async def about_page(request: Request, current=Depends(require_login)):
"""关于 MeshSafe 产品介绍页(登录后访问)。"""
return templates.TemplateResponse(
"about.html",
{
"request": request,
"user": current,
"nav_active": "about",
},
)
# =====================================================================
# 诊断套件 — API 路由(独立 router,挂在 /api/v1/telemetry/*)
# =====================================================================
diag_api_router = APIRouter(prefix="/api/v1/telemetry", tags=["telemetry"])
@diag_api_router.get("/summary")
async def telemetry_summary(current=Depends(require_login)):
"""页面加载时取实时面板数据。"""
from app.diagnostic.scheduler import get_diag_state
from app.diagnostic.config import get_diag_config
cfg = get_diag_config()
state = get_diag_state()
# 引擎状态(实时扫描,不走缓存)
engine_health = {}
try:
from app.main import scanner as _scanner
engine_health = _scanner.health() or {}
except Exception:
pass
return {
"enabled": cfg.enabled,
"docker_status": state.get("docker_status", "none"),
"docker_info": state.get("docker_info", {}),
"rules_cache_count": len(state.get("rules_cache", {})),
"generated_at": state.get("latest", {}).get("generated_at"),
"engine_health": engine_health,
}
@diag_api_router.get("/rules")
async def telemetry_rules(current=Depends(require_login)):
"""所有规则最新结果(带时间戳)。"""
from app.diagnostic.scheduler import get_diag_state
state = get_diag_state()
# 合并缓存 + 最新
latest = state.get("latest", {})
rules = latest.get("rules", [])
return {
"enabled": state.get("enabled", False),
"docker_status": state.get("docker_status", "none"),
"generated_at": latest.get("generated_at"),
"rules": [r.to_dict() if hasattr(r, "to_dict") else r for r in rules],
}
@diag_api_router.get("/errors")
async def telemetry_errors(current=Depends(require_login), limit: int = 20):
"""最近 error 任务列表。"""
from sqlalchemy import desc
db = SessionLocal()
try:
rows = (
db.query(ScanTask)
.filter(ScanTask.verdict == "error")
.order_by(desc(ScanTask.created_at))
.limit(limit)
.all()
)
return {
"count": len(rows),
"errors": [
{
"id": r.id[:16] + "...",
"filename": os.path.basename(r.filename) if r.filename else "",
"reason": r.reason[:200] if r.reason else "",
"score": r.score,
"created_at": _bj_time(r.created_at),
"channel": r.channel,
}
for r in rows
],
}
finally:
db.close()
@diag_api_router.get("/docker-status")
async def telemetry_docker_status(current=Depends(require_login)):
"""Docker 能力检测结果。"""
from app.diagnostic.scheduler import get_diag_state
from app.diagnostic.docker_probe import status_message
state = get_diag_state()
return {
"status": state.get("docker_status", "none"),
"status_message": status_message(state.get("docker_status", "none")),
"info": state.get("docker_info", {}),
}
@diag_api_router.get("/config")
async def telemetry_config(current=Depends(require_login)):
"""当前诊断套件配置快照。"""
from app.diagnostic.config import get_diag_config
return get_diag_config().to_dict()
@diag_api_router.get("/dump")
async def telemetry_dump(current=Depends(require_login)):
"""一键导出 tar.gz 诊断包。"""
from fastapi.responses import StreamingResponse
from app.diagnostic.dumper import build_diagnostics_package
try:
data = build_diagnostics_package()
except Exception as e: # noqa: BLE001
import logging
logging.exception("导出诊断包失败")
raise HTTPException(status_code=500, detail=f"导出失败: {e}")
fname = f"diagnostics-{datetime.now().strftime('%Y%m%d-%H%M%S')}.tar.gz"
return StreamingResponse(
iter([data]),
media_type="application/gzip",
headers={
"Content-Disposition": f'attachment; filename="{fname}"',
"Content-Length": str(len(data)),
},
)