feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交

核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
This commit is contained in:
2026-10-28 19:07:11 +08:00
commit 16f134e328
113 changed files with 24604 additions and 0 deletions

3
app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""MeshSafe 文件安全检测服务。"""
__version__ = "0.1.0"

150
app/config.py Normal file
View File

@@ -0,0 +1,150 @@
"""应用配置加载。
所有配置项均以 ``MESHSAFE_`` 前缀从环境变量读取,敏感项不提供默认值,
缺失时启动即报错,避免出现"用默认弱口令上线"的情况。
"""
from __future__ import annotations
import base64
import functools
from typing import Dict, List, Optional
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MESHSAFE_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# 运行环境
env: str = "production"
log_level: str = "INFO"
timezone: str = "Asia/Shanghai"
# 服务监听
host: str = "0.0.0.0"
port: int = 8443
root_path: str = "/"
# 基础设施
db_url: str
db_echo: bool = False
redis_url: str = "redis://redis:6379/0"
# 主密钥
master_key: str
# API 调用方
api_clients: str = ""
signature_window: int = 300
require_signature: bool = True
sign_response: bool = True
# 控制台
admin_username: str = "admin"
admin_password: str
login_max_attempts: int = 5
login_lock_minutes: int = 15
session_timeout_minutes: int = 30
# ClamAV
clamav_host: str = "clamav"
clamav_port: int = 3310
clamav_timeout: int = 120
clamav_use_clamd: bool = True
# 策略
policy_file: str = "/app/config/policy.yaml"
rules_dir: str = "/app/rules"
sandbox_enabled: bool = False
# 文件处理
max_upload_mb: int = 2048
max_extract_depth: int = 5
max_extract_total_mb: int = 4096
max_extract_ratio: int = 100
temp_ttl_minutes: int = 60
# 合规
audit_retention_days: int = 180
audit_hash_chain: bool = True
report_retention_days: int = 180
# TLS
enable_tls: bool = False
tls_cert_file: str = "/app/certs/server.crt"
tls_key_file: str = "/app/certs/server.key"
trusted_proxy_count: int = 1
@field_validator("master_key")
@classmethod
def _validate_master_key(cls, v: str) -> str:
try:
raw = base64.b64decode(v, validate=True)
except Exception as exc: # noqa: BLE001
raise ValueError("MESHSAFE_MASTER_KEY 必须是合法的 base64 字符串") from exc
if len(raw) < 32:
raise ValueError("MESHSAFE_MASTER_KEY 解码后长度不得少于 32 字节")
return v
@field_validator("policy_file")
@classmethod
def _normalize_policy_file(cls, v: str) -> str:
return v.strip()
@property
def master_key_bytes(self) -> bytes:
return base64.b64decode(self.master_key, validate=True)
@property
def max_upload_bytes(self) -> int:
return self.max_upload_mb * 1024 * 1024
@property
def max_extract_total_bytes(self) -> int:
return self.max_extract_total_mb * 1024 * 1024
@property
def parsed_api_clients(self) -> List[Dict[str, str]]:
"""解析 ``client_id:secret,client_id2:secret2`` 形式的调用方清单。"""
result: List[Dict[str, str]] = []
for item in self.api_clients.split(","):
item = item.strip()
if not item:
continue
if ":" not in item:
raise ValueError(f"非法的 API 调用方配置项: {item!r}")
client_id, secret = item.split(":", 1)
client_id = client_id.strip()
secret = secret.strip()
if not client_id or not secret:
raise ValueError(f"非法的 API 调用方配置项: {item!r}")
result.append({"client_id": client_id, "secret": secret})
return result
def redacted(self) -> Dict[str, object]:
"""用于 /health 等接口展示的脱敏配置快照。"""
return {
"env": self.env,
"require_signature": self.require_signature,
"sign_response": self.sign_response,
"signature_window": self.signature_window,
"max_upload_mb": self.max_upload_mb,
"max_extract_depth": self.max_extract_depth,
"max_extract_ratio": self.max_extract_ratio,
"sandbox_enabled": self.sandbox_enabled,
"audit_retention_days": self.audit_retention_days,
"audit_hash_chain": self.audit_hash_chain,
"api_clients_count": len(self.parsed_api_clients),
}
@functools.lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg]

1782
app/console.py Normal file

File diff suppressed because it is too large Load Diff

41
app/db.py Normal file
View File

@@ -0,0 +1,41 @@
"""数据库连接与会话管理。"""
from __future__ import annotations
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
class Base(DeclarativeBase):
pass
_settings = get_settings()
import os as _os
_pool_size = int(_os.environ.get("DB_POOL_SIZE", "20"))
_max_overflow = int(_os.environ.get("DB_MAX_OVERFLOW", "30"))
_engine_kwargs: dict = dict(
echo=_settings.db_echo,
future=True,
pool_pre_ping=True,
pool_size=_pool_size,
max_overflow=_max_overflow,
)
engine = create_engine(_settings.db_url, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,11 @@
"""MeshSafe Diagnostics Kit v1.0 — 运行时模块。"""
from app.diagnostic.config import DiagConfig, get_diag_config
from app.diagnostic.engine import DiagnosticEngine, RuleResult
__all__ = [
"DiagConfig",
"get_diag_config",
"DiagnosticEngine",
"RuleResult",
]

171
app/diagnostic/config.py Normal file
View File

@@ -0,0 +1,171 @@
"""MeshSafe Diagnostics Kit — 配置读取。
所有配置项可被环境变量覆盖(``MESHSAFE_DIAG_*`` 前缀),
优先级:环境变量 > diagnostics.yaml > 默认值。
"""
from __future__ import annotations
import functools
import os
from pathlib import Path
from typing import Any, Dict
import yaml
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_DEFAULT_YAML = _PROJECT_ROOT / "config" / "diagnostics.yaml"
def _load_yaml(path: Path) -> Dict[str, Any]:
"""读取 YAML 配置,文件不存在返回空 dict。"""
try:
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("diagnostics", {}) if isinstance(data, dict) else {}
except FileNotFoundError:
return {}
except Exception: # noqa: BLE001
return {}
def _env_bool(name: str, default: bool) -> bool:
v = os.environ.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on")
def _env_int(name: str, default: int) -> int:
v = os.environ.get(name)
if v is None:
return default
try:
return int(v)
except ValueError:
return default
def _env_str(name: str, default: str) -> str:
return os.environ.get(name, default)
@functools.lru_cache(maxsize=1)
def get_diag_config() -> "DiagConfig":
yaml_data = _load_yaml(_DEFAULT_YAML)
return DiagConfig.from_dict(yaml_data)
class DiagConfig:
"""诊断套件配置。"""
def __init__(self, yaml_data: Dict[str, Any]):
# 顶层开关
yaml_enabled = yaml_data.get("enabled", True)
self.enabled = _env_bool(
"MESHSAFE_DIAG_ENABLED", yaml_enabled
)
# Docker 子能力
docker_yaml = yaml_data.get("docker", {})
yaml_docker_mode = docker_yaml.get("enabled", "auto")
self.docker_mode = _env_str(
"MESHSAFE_DIAG_DOCKER", yaml_docker_mode
).lower()
if self.docker_mode not in ("auto", "true", "false"):
self.docker_mode = "auto"
# 定时器周期(秒)
timers_yaml = yaml_data.get("timers", {})
self.error_scan_interval = _env_int(
"MESHSAFE_DIAG_ERROR_INTERVAL",
timers_yaml.get("error_scan_interval", 30),
)
self.container_check_interval = _env_int(
"MESHSAFE_DIAG_CONTAINER_INTERVAL",
timers_yaml.get("container_check_interval", 300),
)
self.db_aggregate_interval = _env_int(
"MESHSAFE_DIAG_DB_INTERVAL",
timers_yaml.get("db_aggregate_interval", 60),
)
# 日志行数
self.docker_log_tail = int(yaml_data.get("docker_log_tail", 500))
self.export_log_tail = int(yaml_data.get("export_log_tail", 2000))
# 导出包数据范围
export_yaml = yaml_data.get("export", {})
self.export_db_span_days = int(export_yaml.get("db_span_days", 7))
self.export_scan_tasks_max = int(export_yaml.get("scan_tasks_max", 10000))
self.export_scan_findings_max = int(export_yaml.get("scan_findings_max", 50000))
self.export_engine_errors_max = int(export_yaml.get("engine_errors_max", 5000))
# 规则阈值
rules_yaml = yaml_data.get("rules", {})
self.error_ratio_threshold = float(
rules_yaml.get("error_ratio_threshold", 0.05)
)
self.clamav_db_stale_days = int(
rules_yaml.get("clamav_db_stale_days", 7)
)
self.reject_ratio_threshold = float(
rules_yaml.get("reject_ratio_threshold", 0.20)
)
self.suspicious_exceeds_malicious = bool(
rules_yaml.get("suspicious_exceeds_malicious", True)
)
# 持久化存储
storage_yaml = yaml_data.get("storage", {})
self.storage_enabled = _env_bool(
"MESHSAFE_DIAG_STORAGE_ENABLED",
storage_yaml.get("enabled", True),
)
self.storage_dir = storage_yaml.get("dir", "/app/data/diagnostics")
self.history_retention_days = int(
storage_yaml.get("history_retention_days", 7)
)
self.log_snapshot_on_timer = bool(
storage_yaml.get("log_snapshot_on_timer", True)
)
self.log_snapshot_tail = int(
storage_yaml.get("log_snapshot_tail", 500)
)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "DiagConfig":
return cls(data)
def to_dict(self) -> Dict[str, Any]:
return {
"enabled": self.enabled,
"docker_mode": self.docker_mode,
"timers": {
"error_scan_interval": self.error_scan_interval,
"container_check_interval": self.container_check_interval,
"db_aggregate_interval": self.db_aggregate_interval,
},
"docker_log_tail": self.docker_log_tail,
"export_log_tail": self.export_log_tail,
"export": {
"db_span_days": self.export_db_span_days,
"scan_tasks_max": self.export_scan_tasks_max,
"scan_findings_max": self.export_scan_findings_max,
"engine_errors_max": self.export_engine_errors_max,
},
"rules": {
"error_ratio_threshold": self.error_ratio_threshold,
"clamav_db_stale_days": self.clamav_db_stale_days,
"reject_ratio_threshold": self.reject_ratio_threshold,
"suspicious_exceeds_malicious": self.suspicious_exceeds_malicious,
},
"storage": {
"enabled": self.storage_enabled,
"dir": self.storage_dir,
"history_retention_days": self.history_retention_days,
"log_snapshot_on_timer": self.log_snapshot_on_timer,
"log_snapshot_tail": self.log_snapshot_tail,
},
}

View File

@@ -0,0 +1,126 @@
"""启动时 Docker 能力检测(用 python docker SDK)。
返回三档:
full — docker.sock 可访问,日志和 inspect 都能用
partial — 能连 socket 但权限受限
none — 完全不可用(没挂 socket / 包没装)
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
_PROBE_CONTAINER = "meshsafe-api"
def _try_client() -> Optional["docker.DockerClient"]:
"""尝试创建 Docker client。失败返回 None。"""
try:
import docker # noqa: F401
except ImportError:
return None
try:
import docker
# from_env() 会自动读 DOCKER_HOST,没设的话走 unix:///var/run/docker.sock
client = docker.from_env(timeout=5)
client.ping() # 真正连一下
return client
except Exception: # noqa: BLE001
return None
def detect_docker_access(mode: str = "auto") -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
sock_exists = os.path.exists("/var/run/docker.sock")
client = _try_client()
if mode == "false":
return {
"mode": "false", "status": "none",
"docker.sock_exists": sock_exists,
"docker_python_available": client is not None,
"can_logs": False, "can_inspect": False,
"containers": None,
"message": "配置强制关闭 Docker 能力(diagnostics.docker.enabled=false)",
"detected_at": now,
}
if client is None:
if mode == "true":
msg = "配置强制启用但 Docker Python SDK 不可用 — pip install docker"
else:
msg = "Docker SDK 未就绪 — 日志匹配/容器状态类规则已跳过"
return {
"mode": mode, "status": "none",
"docker.sock_exists": sock_exists,
"docker_python_available": False,
"can_logs": False, "can_inspect": False,
"containers": None,
"message": msg,
"detected_at": now,
}
# 真测
can_logs = False
can_inspect = False
containers: Optional[List[str]] = None
try:
c = client.containers.get(_PROBE_CONTAINER)
c.logs(tail=1)
can_logs = True
c.attrs # 读取属性
can_inspect = True
except Exception: # noqa: BLE001
pass
try:
containers = [c.name for c in client.containers.list(all=True)]
except Exception: # noqa: BLE001
containers = None
if can_logs and can_inspect:
status = "full"
msg = "Docker 访问正常 — 日志匹配 + 容器状态检测全部启用"
elif can_logs or can_inspect:
status = "partial"
msg = f"Docker SDK 可用但权限受限(logs={can_logs}, inspect={can_inspect})"
else:
status = "none"
msg = "docker.sock 未挂或权限不足 — 日志匹配类规则已跳过"
if mode == "true" and status != "full":
msg = f"配置强制启用但检测结果为 {status} — 检查 docker.sock 挂载"
return {
"mode": mode, "status": status,
"docker.sock_exists": sock_exists,
"docker_python_available": True,
"can_logs": can_logs,
"can_inspect": can_inspect,
"containers": containers,
"message": msg,
"detected_at": now,
}
def status_message(status: str) -> str:
return {
"full": "✅ Docker 能力完整",
"partial": "⚠️ Docker 访问受限",
"none": "ℹ️ Docker 日志检测未启用",
}.get(status, status)
def get_client():
"""获取共享 docker client(给 dumper / rules 用)。失败返回 None。"""
try:
import docker
return docker.from_env(timeout=10)
except Exception: # noqa: BLE001
return None

477
app/diagnostic/dumper.py Normal file
View File

@@ -0,0 +1,477 @@
"""一键导出诊断包(diagnostics-YYYYMMDD-HHMMSS.tar.gz)。
调用方式(HTTP 接口):
GET /api/v1/telemetry/dump → 返回文件下载
打包内容见方案文档 §3.4。
"""
from __future__ import annotations
import io
import json
import os
import subprocess
import tarfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from app.config import get_settings
from app.db import SessionLocal
from app.diagnostic.config import get_diag_config
from app.diagnostic.engine import DiagnosticEngine
from app.diagnostic.docker_probe import detect_docker_access
def _sanitize_filename(name: str) -> str:
"""文件名脱敏:保留扩展名 + SHA256 前 8 位。"""
if not name:
return ""
ext = os.path.splitext(name)[1].lower()
# 截取前 20 字符 + hash-like 后缀
return f"file_{hash(name) % 999999:06d}{ext}"
def _sanitize_ip(ip: str) -> str:
if not ip:
return ""
parts = ip.split(".")
if len(parts) == 4:
return f"{'.'.join(parts[:3])}.*"
return ip
def _bj_time(dt) -> Optional[str]:
"""把 DB 里的 UTC datetime(naive 或 aware)转北京时间字符串。"""
if dt is None:
return None
bj = timezone(timedelta(hours=8))
try:
# naive UTC → 加上 tz 再转
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
bj_dt = dt.astimezone(bj)
return bj_dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return str(dt)
def _collect_scan_tasks(days: int = 7, max_rows: int = 10000) -> List[Dict[str, Any]]:
"""拉过去 N 天的 ScanTask,脱敏后返回 dict 列表。有 max_rows 兜底。"""
from app.models import ScanTask
db = SessionLocal()
try:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
db.query(ScanTask)
.filter(ScanTask.created_at >= cutoff)
.order_by(ScanTask.created_at.desc())
.limit(max_rows)
.all()
)
return [
{
"id": r.id,
"filename_sanitized": _sanitize_filename(r.filename),
"declared_ext": r.declared_ext,
"size": r.size,
"sha256": r.sha256[:16],
"channel": r.channel,
"status": r.status,
"mode": r.mode,
"verdict": r.verdict,
"action": r.action,
"score": r.score,
"reason": r.reason[:500] if r.reason else "",
"policy_version": r.policy_version,
"created_at": _bj_time(r.created_at),
"finished_at": _bj_time(r.finished_at),
"duration_ms": r.duration_ms,
}
for r in rows
]
finally:
db.close()
def _collect_scan_findings(days: int = 7, max_rows: int = 50000) -> List[Dict[str, Any]]:
from app.models import ScanFinding
db = SessionLocal()
try:
# ScanFinding 没有 created_at 字段?查 ScanTask 关联时间
from app.models import ScanTask
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
db.query(ScanFinding)
.join(ScanTask, ScanFinding.task_id == ScanTask.id)
.filter(ScanTask.created_at >= cutoff)
.order_by(ScanFinding.id.desc())
.limit(max_rows)
.all()
)
return [
{
"task_id": r.task_id,
"engine": r.engine,
"layer": r.layer,
"severity": r.severity,
"rule": r.rule,
"weight": r.weight,
"detail": (r.detail[:2000] if r.detail else ""),
"extracted_path": r.extracted_path,
}
for r in rows
]
finally:
db.close()
def _collect_engine_errors(days: int = 7, max_rows: int = 5000) -> List[Dict[str, Any]]:
from app.models import ScanTask
db = SessionLocal()
try:
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
db.query(ScanTask)
.filter(ScanTask.verdict == "error")
.filter(ScanTask.created_at >= cutoff)
.order_by(ScanTask.created_at.desc())
.limit(max_rows)
.all()
)
return [
{
"id": r.id,
"filename_sanitized": _sanitize_filename(r.filename),
"reason": r.reason[:500] if r.reason else "",
"created_at": _bj_time(r.created_at),
}
for r in rows
]
finally:
db.close()
def _collect_policy_snapshot() -> Dict[str, Any]:
"""策略版本 + YARA 规则列表(不含规则原文)。"""
from app.main import policy_store
policy = policy_store.current()
settings = get_settings()
rules_dir = settings.rules_dir
yara_rules: List[str] = []
if os.path.isdir(rules_dir):
yara_rules = sorted(
f for f in os.listdir(rules_dir)
if f.endswith((".yar", ".yara", ".rules"))
)
return {
"version": policy.version,
"sha256": policy.sha256,
"yara_rules_dir": rules_dir,
"yara_rules_count": len(yara_rules),
"yara_rules_list": yara_rules[:50], # 最多 50 个文件名
"global": policy.raw.get("global", {}),
}
def _collect_container_logs(containers: List[str], tail: int) -> Dict[str, str]:
"""用 docker SDK 取日志。"""
from app.diagnostic.docker_probe import get_client
client = get_client()
result: Dict[str, str] = {}
for c in containers:
try:
container = client.containers.get(c)
raw = container.logs(tail=tail)
result[c] = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
except Exception as e: # noqa: BLE001
result[c] = f"[导出失败: {e}]"
return result
def _collect_container_meta(containers: List[str]) -> Dict[str, Dict[str, Any]]:
"""用 docker SDK 取容器状态元数据(含 uptime / OOM)。"""
from app.diagnostic.docker_probe import get_client
client = get_client()
meta: Dict[str, Dict[str, Any]] = {}
now = datetime.now(timezone.utc)
for c in containers:
try:
container = client.containers.get(c)
attrs = container.attrs or {}
state = attrs.get("State", {})
started_raw = state.get("StartedAt", "")
started_at = _docker_time(started_raw)
uptime_s = (now - started_at).total_seconds() if started_at else -1
meta[c] = {
"status": state.get("Status", "unknown"),
"health": state.get("Health", {}).get("Status", ""),
"restart_count": attrs.get("RestartCount", -1),
"oom_killed": state.get("OOMKilled", False),
"exit_code": state.get("ExitCode", 0),
"started_at": _bj_time(started_at),
"uptime": _fmt_duration(uptime_s),
"uptime_seconds": int(uptime_s) if uptime_s >= 0 else -1,
"image": attrs.get("Config", {}).get("Image", ""),
}
except Exception as e: # noqa: BLE001
meta[c] = {"error": str(e)[:200]}
return meta
def _collect_compose_state() -> Dict[str, Any]:
"""用 docker SDK 列容器(替代 docker compose ps,输出结构化 JSON)。"""
from app.diagnostic.docker_probe import get_client
client = get_client()
now = datetime.now(timezone.utc)
items: List[Dict[str, Any]] = []
try:
for c in client.containers.list(all=True):
attrs = c.attrs or {}
state = attrs.get("State", {})
started_raw = state.get("StartedAt", "")
started_at = _docker_time(started_raw)
uptime_s = (now - started_at).total_seconds() if started_at else -1
items.append({
"name": c.name,
"status": state.get("Status", "unknown"),
"health": state.get("Health", {}).get("Status", ""),
"restart_count": attrs.get("RestartCount", -1),
"oom_killed": state.get("OOMKilled", False),
"exit_code": state.get("ExitCode", 0),
"image": c.image.tags[0] if c.image.tags else "unknown",
"started_at": _bj_time(started_at),
"uptime": _fmt_duration(uptime_s),
})
except Exception as e: # noqa: BLE001
return {"error": str(e)[:300], "containers": []}
return {"containers": items, "count": len(items)}
def _docker_time(raw: str) -> Optional[datetime]:
"""把 docker 返回的 ISO 时间串转 aware UTC datetime。"""
if not raw:
return None
try:
# docker 返回: "2026-10-28T05:00:12.123456789Z" 或 ".123Z"
s = raw.replace("Z", "+00:00")
# 截断纳秒到微秒(Python fromisoformat 不接受纳秒)
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
return datetime.fromisoformat(s)
except Exception:
return None
def _fmt_duration(seconds: float) -> str:
"""把秒数格式化成人类可读的时长。"""
if seconds < 0:
return "未知"
s = int(seconds)
days, rem = divmod(s, 86400)
hours, rem = divmod(rem, 3600)
mins, secs = divmod(rem, 60)
if days > 0:
return f"{days}d {hours}h {mins}m"
if hours > 0:
return f"{hours}h {mins}m"
if mins > 0:
return f"{mins}m {secs}s"
return f"{secs}s"
# ======================================================================
# 主入口:build_diagnostics_package
# ======================================================================
def build_diagnostics_package() -> bytes:
"""生成完整诊断包,返回 tar.gz 字节。"""
started = time.time()
now = datetime.now(timezone.utc)
cfg = get_diag_config()
settings = get_settings()
containers = [
"meshsafe-api", "meshsafe-postgres",
"meshsafe-redis", "meshsafe-clamav", "meshsafe-nginx",
]
# ---- 1. docker 能力 ----
docker_info = detect_docker_access(mode=cfg.docker_mode)
# ---- 2. 导出前先存当前 docker 日志快照(持久化目录里)----
snapshot_files: List[Path] = []
try:
from app.diagnostic.storage import snapshot_container_logs
if cfg.storage_enabled and docker_info["status"] != "none":
snapshot_files = snapshot_container_logs(
containers, cfg.export_log_tail,
filename_prefix="export",
)
except Exception: # noqa: BLE001
snapshot_files = []
# ---- 3. 规则引擎(全量跑一遍)----
db = SessionLocal()
from app.main import scanner, policy_store
eng = DiagnosticEngine(
db=db, scanner=scanner, policy_store=policy_store,
docker_status=docker_info["status"],
docker_log_tail=cfg.docker_log_tail,
)
rule_results = eng.run_all(run_by="export")
db.close()
# ---- 4. 加载规则历史(持久化 JSONL)----
rule_history: List[Dict[str, Any]] = []
restart_events: List[Dict[str, Any]] = []
try:
from app.diagnostic.storage import load_rule_history, load_restart_events
if cfg.storage_enabled:
rule_history = load_rule_history(cfg.history_retention_days)
restart_events = load_restart_events(cfg.history_retention_days)
except Exception: # noqa: BLE001
rule_history = []
restart_events = []
# 按容器聚合重启事件数(用于 rules_report 的 summary)
restart_by_container: Dict[str, int] = {}
for ev in restart_events:
c = ev.get("container", "?")
restart_by_container[c] = restart_by_container.get(c, 0) + 1
# ---- 5. DB 数据(默认过去 7 天,各有上限兜底)----
span_days = getattr(cfg, "export_db_span_days", 7)
scan_tasks = _collect_scan_tasks(
days=span_days, max_rows=getattr(cfg, "export_scan_tasks_max", 10000),
)
scan_findings = _collect_scan_findings(
days=span_days, max_rows=getattr(cfg, "export_scan_findings_max", 50000),
)
engine_errors = _collect_engine_errors(
days=span_days, max_rows=getattr(cfg, "export_engine_errors_max", 5000),
)
policy_snapshot = _collect_policy_snapshot()
# ---- 6. 容器数据(如果 Docker 可用)----
docker_logs_out: Dict[str, str] = {}
container_meta: Dict[str, Dict[str, Any]] = {}
compose_state: Dict[str, Any] = {"containers": [], "error": "Docker 不可用"}
if docker_info["status"] != "none":
docker_logs_out = _collect_container_logs(containers, cfg.export_log_tail)
container_meta = _collect_container_meta(containers)
compose_state = _collect_compose_state()
# ---- 7. 组装 tar.gz ----
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
def _add_json(name: str, obj: Any) -> None:
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
def _add_text(name: str, text: str) -> None:
data = text.encode("utf-8")
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
# meta.json
_add_json("meta.json", {
"exported_at": now.isoformat(),
"generated_by": "MeshSafe Diagnostics Kit v1.0",
"meshsafe_version": "1.1.0",
"docker_image": container_meta.get("meshsafe-api", {}).get("image", "unknown"),
"policy_version": policy_snapshot["version"],
"storage_enabled": cfg.storage_enabled,
"rules_history_count": len(rule_history),
"log_snapshots_included": len(snapshot_files),
"export_duration_ms": int((time.time() - started) * 1000),
# 数据范围
"db_span_days": getattr(cfg, "export_db_span_days", 7),
"scan_tasks_count": len(scan_tasks),
"scan_findings_count": len(scan_findings),
"engine_errors_count": len(engine_errors),
})
# docker_detect.json
_add_json("docker_detect.json", docker_info)
# compose_state.json(结构化 JSON)
_add_json("compose_state.json", compose_state)
# rules_report.json(实时全量 + 持久化历史 + 重启事件汇总)
_add_json("rules_report.json", {
"exported_at": now.isoformat(),
"docker_status": docker_info["status"],
"current_run": [r.to_dict() for r in rule_results],
"history_retention_days": cfg.history_retention_days,
"history_count": len(rule_history),
"history": rule_history,
"restart_events_count": len(restart_events),
"restart_events_summary": restart_by_container,
})
# restart_events.json(完整重启事件历史)
_add_json("restart_events.json", {
"exported_at": now.isoformat(),
"retention_days": cfg.history_retention_days,
"count": len(restart_events),
"events": restart_events,
})
# scan_tasks.json / scan_findings.json / engine_errors.json
_add_json("scan_tasks.json", scan_tasks)
_add_json("scan_findings.json", scan_findings)
_add_json("engine_errors.json", engine_errors)
# policy_snapshot.json
_add_json("policy_snapshot.json", policy_snapshot)
# engine_health.json
_add_json("engine_health.json", scanner.health())
# containers/*.log / *.meta.json(当前快照)
for c in containers:
if c in docker_logs_out:
_add_text(f"containers/{c}.log", docker_logs_out[c])
if c in container_meta:
_add_json(f"containers/{c}.meta.json", container_meta[c])
# diagnostics_logs/(持久化目录下所有 .log — timer 快照 + export 快照 + restart 快照)
try:
from app.diagnostic.storage import _logs_dir
logs_dir = _logs_dir()
if cfg.storage_enabled and logs_dir.exists():
for fpath in logs_dir.glob("*.log"):
try:
tar.add(
str(fpath),
arcname=f"diagnostics_logs/{fpath.name}",
)
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001
# 回退:只用 export 生成的快照
for fpath in snapshot_files:
try:
tar.add(str(fpath), arcname=f"diagnostics_logs/{fpath.name}")
except Exception:
pass
return buf.getvalue()

228
app/diagnostic/engine.py Normal file
View File

@@ -0,0 +1,228 @@
"""诊断规则引擎。
职责:
- 定义 RuleResult 数据结构(每条规则的输出格式)
- 提供 DiagnosticEngine.run_all() 入口
- 规则实现分散在 rules.py 中,engine 负责调度
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
@dataclass
class RuleResult:
"""单条规则的输出。"""
id: str # R001, R002 ...
severity: str # high / medium / low / info / ok
message: str # 给用户看的中文描述
hint: str = "" # 建议/下一步操作
evidence: List[str] = field(default_factory=list) # 证据片段(日志行/DB 统计)
run_at: str = "" # ISO 格式时间戳
run_by: str = "page_request" # page_request / timer:xxx / export
skipped: bool = False # 规则是否跳过
skip_reason: Optional[str] = None # 为什么跳过(如 Docker 不可用)
def __post_init__(self) -> None:
if not self.run_at:
# 用北京时间(Asia/Shanghai, UTC+8),避免显示差 8 小时
try:
bj = timezone(timedelta(hours=8))
self.run_at = datetime.now(bj).strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
self.run_at = datetime.now(timezone.utc).isoformat()
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
# ---------------------------------------------------------------------------
# 严重性 → 徽章 CSS 类映射(和 _nav_style.html 保持一致)
# ---------------------------------------------------------------------------
SEVERITY_BADGE: Dict[str, str] = {
"high": "tag bad", # 红色
"medium": "tag warn", # 橙色
"low": "tag notice", # 黄色
"info": "tag muted", # 灰色
"ok": "tag ok", # 绿色
}
def badge_for(severity: str) -> str:
return SEVERITY_BADGE.get(severity, "tag plain")
# ---------------------------------------------------------------------------
# DiagnosticEngine — 规则调度入口
# ---------------------------------------------------------------------------
class DiagnosticEngine:
"""诊断规则引擎。
用法::
engine = DiagnosticEngine(db=SessionLocal(), scanner=scanner,
policy_store=policy_store, docker_status="full")
results = engine.run_all(run_by="page_request")
"""
def __init__(
self,
db, # SQLAlchemy Session
scanner, # Scanner 实例
policy_store, # PolicyStore 实例
docker_status: str = "none", # full / partial / none
docker_log_tail: int = 500,
):
self.db = db
self.scanner = scanner
self.policy_store = policy_store
self.docker_status = docker_status
self.docker_log_tail = docker_log_tail
# ------------------------------------------------------------------
# 入口方法
# ------------------------------------------------------------------
def run_all(self, run_by: str = "page_request") -> List[RuleResult]:
"""运行全部 15 条规则,返回结果列表。"""
# 延迟导入,避免循环依赖
from app.diagnostic import rules as _rules
results: List[RuleResult] = []
all_funcs = [
_rules.check_r001_error_ratio,
_rules.check_r002_clamav_db,
_rules.check_r003_yara_mtime,
_rules.check_r004_engine_health,
_rules.check_r005_no_scans,
_rules.check_r006_suspicious_over_malicious,
_rules.check_r007_reject_ratio,
_rules.check_r008_duplicate_sha,
# 以下 7 条需要 Docker 能力
_rules.check_r009_clamav_crash,
_rules.check_r010_postgres_fatal,
_rules.check_r011_redis_oom,
_rules.check_r012_no_space,
_rules.check_r013_nginx_upstream,
_rules.check_r014_api_connection,
_rules.check_r015_container_restart,
_rules.check_r016_restart_history,
]
for fn in all_funcs:
try:
results.append(fn(self, run_by=run_by))
except Exception as e: # noqa: BLE001
# 单条规则失败不影响整体
results.append(RuleResult(
id=getattr(fn, "_rule_id", "UNKNOWN"),
severity="info",
message=f"规则执行异常: {e}",
skipped=True,
skip_reason=str(e),
run_by=run_by,
))
return results
def run_page_request_rules(self) -> List[RuleResult]:
"""页面请求时必跑的轻量规则(R001/R002/R003/R004/R005)。"""
from app.diagnostic import rules as _rules
results: List[RuleResult] = []
for fn in [
_rules.check_r001_error_ratio,
_rules.check_r002_clamav_db,
_rules.check_r003_yara_mtime,
_rules.check_r004_engine_health,
_rules.check_r005_no_scans,
]:
try:
results.append(fn(self, run_by="page_request"))
except Exception as e: # noqa: BLE001
results.append(RuleResult(
id=getattr(fn, "_rule_id", "UNKNOWN"),
severity="info",
message=f"规则执行异常: {e}",
skipped=True,
skip_reason=str(e),
))
return results
def run_timer_error_scan(self) -> List[RuleResult]:
"""定时器 A:错误类日志(30s 周期)。"""
from app.diagnostic import rules as _rules
results: List[RuleResult] = []
for fn in [
_rules.check_r001_error_ratio,
_rules.check_r005_no_scans,
_rules.check_r009_clamav_crash,
_rules.check_r010_postgres_fatal,
_rules.check_r011_redis_oom,
_rules.check_r012_no_space,
_rules.check_r013_nginx_upstream,
_rules.check_r014_api_connection,
]:
try:
results.append(fn(self, run_by="timer:error_scan"))
except Exception as e: # noqa: BLE001
results.append(RuleResult(
id=getattr(fn, "_rule_id", "UNKNOWN"),
severity="info",
message=f"规则执行异常: {e}",
skipped=True,
skip_reason=str(e),
))
return results
def run_timer_db_aggregate(self) -> List[RuleResult]:
"""定时器 B:DB 聚合类(60s 周期)。"""
from app.diagnostic import rules as _rules
results: List[RuleResult] = []
for fn in [
_rules.check_r006_suspicious_over_malicious,
_rules.check_r007_reject_ratio,
_rules.check_r008_duplicate_sha,
]:
try:
results.append(fn(self, run_by="timer:db_aggregate"))
except Exception as e: # noqa: BLE001
results.append(RuleResult(
id=getattr(fn, "_rule_id", "UNKNOWN"),
severity="info",
message=f"规则执行异常: {e}",
skipped=True,
skip_reason=str(e),
))
return results
def run_timer_container(self) -> List[RuleResult]:
"""定时器 C:容器/引擎类(300s 周期)。"""
from app.diagnostic import rules as _rules
results: List[RuleResult] = []
for fn in [
_rules.check_r002_clamav_db,
_rules.check_r003_yara_mtime,
_rules.check_r004_engine_health,
_rules.check_r015_container_restart,
_rules.check_r016_restart_history,
]:
try:
results.append(fn(self, run_by="timer:container_check"))
except Exception as e: # noqa: BLE001
results.append(RuleResult(
id=getattr(fn, "_rule_id", "UNKNOWN"),
severity="info",
message=f"规则执行异常: {e}",
skipped=True,
skip_reason=str(e),
))
return results

878
app/diagnostic/rules.py Normal file
View File

@@ -0,0 +1,878 @@
"""诊断规则实现(15 条)。
规则按类型分四组:
页面必跑(轻量): R001 / R002 / R003 / R004 / R005
DB 聚合: R006 / R007 / R008
日志匹配: R009-R014(需要 docker.sock)
容器状态: R015(需要 docker.sock)
"""
from __future__ import annotations
import os
import subprocess
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import func
from app.config import get_settings
from app.diagnostic.config import get_diag_config
from app.diagnostic.engine import DiagnosticEngine, RuleResult
# ======================================================================
# 模块级状态缓存(用于检测重启事件)
# key: container_name
# value: {"restart_count": int, "started_at": str, "checked_at": float}
# ======================================================================
_CONTAINER_LAST_STATE: Dict[str, Dict[str, Any]] = {}
def _ok(rule_id: str, message: str, hint: str = "",
evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult:
return RuleResult(
id=rule_id, severity="ok", message=message, hint=hint,
evidence=evidence or [], run_by=run_by,
)
def _warn(rule_id: str, message: str, hint: str = "",
evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult:
return RuleResult(
id=rule_id, severity="medium", message=message, hint=hint,
evidence=evidence or [], run_by=run_by,
)
def _crit(rule_id: str, message: str, hint: str = "",
evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult:
return RuleResult(
id=rule_id, severity="high", message=message, hint=hint,
evidence=evidence or [], run_by=run_by,
)
def _skip(rule_id: str, reason: str, run_by: str = "") -> RuleResult:
return RuleResult(
id=rule_id, severity="info", message=f"规则未运行: {reason}",
skipped=True, skip_reason=reason, run_by=run_by,
)
# ======================================================================
# 工具函数
# ======================================================================
def _fetch_recent_verdict_counts(engine: DiagnosticEngine, limit: int = 100) -> Dict[str, int]:
"""查最近 N 次扫描的 verdict 分布。
用子查询先 limit 再 group_by,兼容 SQLAlchemy 2.0。
"""
from sqlalchemy import select
from app.models import ScanTask
# 先子查询拿到最近 N 条 verdict
sub_stmt = (
select(ScanTask.verdict)
.order_by(ScanTask.created_at.desc())
.limit(limit)
).subquery()
# 再在外层 group_by 统计
rows = engine.db.execute(
select(sub_stmt.c.verdict, func.count(sub_stmt.c.verdict))
.group_by(sub_stmt.c.verdict)
).all()
return {v or "unknown": int(c) for v, c in rows}
def _count_24h(engine: DiagnosticEngine) -> int:
from app.models import ScanTask
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
return (
engine.db.query(func.count(ScanTask.id))
.filter(ScanTask.created_at >= cutoff)
.scalar() or 0
)
def _fetch_recent_tasks(engine: DiagnosticEngine,
limit: int = 500) -> List[Any]:
from app.models import ScanTask
return (
engine.db.query(ScanTask)
.order_by(ScanTask.created_at.desc())
.limit(limit)
.all()
)
def _docker_logs(container: str, tail: int = 500) -> str:
"""用 docker SDK 取容器日志。失败返回空字符串。"""
try:
from app.diagnostic.docker_probe import get_client
client = get_client()
if not client:
return ""
c = client.containers.get(container)
raw = c.logs(tail=tail)
return raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else str(raw)
except Exception: # noqa: BLE001
return ""
def _docker_inspect(container: str) -> Optional[Dict[str, Any]]:
"""用 docker SDK 取容器状态元数据。失败返回 None。"""
try:
from app.diagnostic.docker_probe import get_client
client = get_client()
if not client:
return None
c = client.containers.get(container)
attrs = c.attrs or {}
state = attrs.get("State", {})
return {
"status": state.get("Status", "unknown"),
"restart_count": attrs.get("RestartCount", 0),
"started_at": state.get("StartedAt", ""),
"image": attrs.get("Config", {}).get("Image", ""),
}
except Exception: # noqa: BLE001
return None
def _grep_patterns(text: str, patterns: List[str],
max_lines: int = 20) -> List[str]:
"""从日志文本中抓取命中 pattern 的行。"""
hits: List[str] = []
for line in text.splitlines():
low = line.lower()
for p in patterns:
if p.lower() in low:
hits.append(line.strip())
break
if len(hits) >= max_lines:
break
return hits
# ======================================================================
# R001: error 比例
# ======================================================================
def check_r001_error_ratio(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
cfg = get_diag_config()
counts = _fetch_recent_verdict_counts(engine, 100)
total = sum(counts.values())
if total == 0:
return _ok("R001", "暂无扫描记录", run_by=run_by)
error_count = counts.get("error", 0)
error_ratio = error_count / total
threshold = cfg.error_ratio_threshold
if error_ratio > threshold:
return _crit(
"R001",
f"错误比例偏高({error_ratio:.1%} > 阈值 {threshold:.0%})",
f"近 100 次扫描 {error_count} 次 error",
hint="建议检查 ClamAV / Redis / Postgres 引擎健康",
evidence=[f"近 100 次 verdict 分布: {counts}"],
run_by=run_by,
)
return _ok(
"R001",
f"错误比例正常({error_ratio:.1%},阈值 {threshold:.0%})",
run_by=run_by,
)
check_r001_error_ratio._rule_id = "R001"
# ======================================================================
# R002: ClamAV 病毒库日期
# ======================================================================
def check_r002_clamav_db(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
cfg = get_diag_config()
try:
clamav_info = engine.scanner.health().get("clamav", {})
except Exception as e: # noqa: BLE001
return _skip("R002", f"ClamAV 健康检查异常: {e}", run_by=run_by)
sig_date_str = clamav_info.get("signature_date")
if not sig_date_str:
available = clamav_info.get("available", clamav_info.get("ok", False))
if not available:
return _crit(
"R002", "ClamAV 引擎不可用",
hint="检查 clamd 容器是否在运行",
run_by=run_by,
)
return _warn(
"R002", "ClamAV 病毒库日期未知",
hint="在线更新失败?U 盘离线导入",
run_by=run_by,
)
# signature_date 可能是字符串如 "2026-10-14"
try:
sig_date = datetime.strptime(str(sig_date_str)[:10], "%Y-%m-%d").date()
except Exception: # noqa: BLE001
return _skip("R002", f"无法解析病毒库日期: {sig_date_str}", run_by=run_by)
days_old = (datetime.now().date() - sig_date).days
stale = cfg.clamav_db_stale_days
if days_old > stale:
return _warn(
"R002",
f"ClamAV 病毒库已 {days_old} 天未更新",
hint="在线更新不可用 → U 盘离线导入 main.cvd / daily.cvd",
evidence=[f"signature_date = {sig_date_str}"],
run_by=run_by,
)
return _ok(
"R002",
f"ClamAV 病毒库新鲜({sig_date_str},{days_old} 天)",
run_by=run_by,
)
check_r002_clamav_db._rule_id = "R002"
# ======================================================================
# R003: YARA 规则 mtime
# ======================================================================
def check_r003_yara_mtime(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
settings = get_settings()
rules_dir = settings.rules_dir
if not os.path.isdir(rules_dir):
return _skip("R003", f"规则目录不存在: {rules_dir}", run_by=run_by)
yamls = [f for f in os.listdir(rules_dir) if f.endswith((".yar", ".yara", ".rules"))]
if not yamls:
return _warn(
"R003", "YARA 规则目录为空",
hint="检查 rules_dir 配置是否正确",
run_by=run_by,
)
latest_mtime = max(
os.path.getmtime(os.path.join(rules_dir, f)) for f in yamls
)
latest_date = datetime.fromtimestamp(latest_mtime).date()
days_old = (datetime.now().date() - latest_date).days
# 对比策略版本发布时间(如果策略文件比规则新,说明规则可能没更新)
policy_file = settings.policy_file
policy_mtime = None
if os.path.isfile(policy_file):
policy_mtime = os.path.getmtime(policy_file)
evidence = [f"规则目录: {rules_dir}", f"规则文件数: {len(yamls)}",
f"最新规则 mtime: {latest_date}"]
if policy_mtime and policy_mtime > latest_mtime:
evidence.append(f"策略文件比规则新(策略: {datetime.fromtimestamp(policy_mtime).date()})")
return _warn(
"R003",
"策略文件比 YARA 规则新,规则可能未随策略版本更新",
hint="同步更新规则文件或确认规则已加载",
evidence=evidence,
run_by=run_by,
)
return _ok(
"R003",
f"YARA 规则正常({len(yamls)} 条规则,最新 {latest_date},{days_old} 天)",
evidence=evidence,
run_by=run_by,
)
check_r003_yara_mtime._rule_id = "R003"
# ======================================================================
# R004: 引擎健康
# ======================================================================
def check_r004_engine_health(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
try:
health = engine.scanner.health()
except Exception as e: # noqa: BLE001
return _crit("R004", f"引擎健康检查异常: {e}", run_by=run_by)
offline: List[str] = []
for name, info in health.items():
ok = info.get("ok", info.get("available", False))
if not ok:
offline.append(name)
if offline:
return _crit(
"R004",
f"以下引擎不可用: {', '.join(offline)}",
hint="检查对应容器状态和网络连通",
evidence=[f"{n}: {health.get(n, {})}" for n in offline],
run_by=run_by,
)
names = list(health.keys())
return _ok(
"R004",
f"所有 {len(names)} 个引擎正常在线",
run_by=run_by,
)
check_r004_engine_health._rule_id = "R004"
# ======================================================================
# R005: 24h 无扫描
# ======================================================================
def check_r005_no_scans(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
count = _count_24h(engine)
if count == 0:
return _warn(
"R005", "过去 24 小时内没有扫描记录",
hint="摆渡系统可能停了;如果是正常停工可以忽略",
run_by=run_by,
)
return _ok(
"R005", f"过去 24 小时 {count} 次扫描",
run_by=run_by,
)
check_r005_no_scans._rule_id = "R005"
# ======================================================================
# R006: suspicious > malicious
# ======================================================================
def check_r006_suspicious_over_malicious(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
cfg = get_diag_config()
if not cfg.suspicious_exceeds_malicious:
return _skip("R006", "规则已在配置中禁用", run_by=run_by)
counts = _fetch_recent_verdict_counts(engine, 200)
suspicious = counts.get("suspicious", 0)
malicious = counts.get("malicious", 0)
if suspicious > malicious and suspicious > 0:
return _warn(
"R006",
f"可疑数({suspicious})超过恶意数({malicious})",
hint="suspicious 阈值配置可能过松;建议调紧让更多文件直接判为 malicious",
evidence=[f"近 200 次 verdict 分布: {counts}"],
run_by=run_by,
)
return _ok(
"R006",
f"suspicious={suspicious} vs malicious={malicious}",
run_by=run_by,
)
check_r006_suspicious_over_malicious._rule_id = "R006"
# ======================================================================
# R007: reject 比例
# ======================================================================
def check_r007_reject_ratio(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
cfg = get_diag_config()
counts = _fetch_recent_verdict_counts(engine, 200)
total = sum(counts.values())
if total == 0:
return _ok("R007", "暂无扫描记录", run_by=run_by)
reject = counts.get("reject", 0)
ratio = reject / total
threshold = cfg.reject_ratio_threshold
if ratio > threshold:
return _warn(
"R007",
f"策略拒绝比例偏高({ratio:.1%} > 阈值 {threshold:.0%})",
hint="检查拒绝原因 TOP;如果只是正常扩展名策略可以调宽",
evidence=[f"近 200 次 verdict 分布: {counts}"],
run_by=run_by,
)
return _ok(
"R007",
f"策略拒绝比例正常({ratio:.1%})",
run_by=run_by,
)
check_r007_reject_ratio._rule_id = "R007"
# ======================================================================
# R008: 同 SHA256 重复扫描
# ======================================================================
def check_r008_duplicate_sha(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
from app.models import ScanTask
cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
rows = (
engine.db.query(ScanTask.sha256, func.count(ScanTask.id))
.filter(ScanTask.created_at >= cutoff)
.group_by(ScanTask.sha256)
.having(func.count(ScanTask.id) > 1)
.all()
)
if rows:
dups = [(sha[:12], c) for sha, c in rows]
return _crit(
"R008",
f"近 1 小时有 {len(rows)} 个 SHA256 被重复扫描",
hint="幂等机制可能失效;检查 idempotency_records 表写入",
evidence=[f"sha256={sha} count={c}" for sha, c in dups[:10]],
run_by=run_by,
)
return _ok("R008", "幂等机制正常(无重复 SHA256)", run_by=run_by)
check_r008_duplicate_sha._rule_id = "R008"
# ======================================================================
# 日志匹配型规则(需要 docker.sock)
# ======================================================================
def _check_docker_available(engine: DiagnosticEngine) -> Optional[RuleResult]:
"""如果 Docker 不可用,返回 skip RuleResult;否则返回 None(继续执行)。"""
if engine.docker_status == "none":
return _skip(None, "Docker 能力未启用", run_by="")
return None
def check_r009_clamav_crash(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R009"
return skip
cfg = get_diag_config()
logs = _docker_logs("meshsafe-clamav", cfg.docker_log_tail)
patterns = ["Killed", "exit 137", "segfault", "oom-killed", "Out of memory"]
hits = _grep_patterns(logs, patterns)
# 同时检查 docker inspect 的 restart_count
info = _docker_inspect("meshsafe-clamav")
restart_count = info.get("restart_count", 0) if info else 0
if hits or restart_count > 3:
ev = hits[:5] if hits else []
if restart_count > 3:
ev.append(f"docker inspect restart_count={restart_count}")
return _crit(
"R009",
f"ClamAV 可能崩溃(restart_count={restart_count},日志命中 {len(hits)} 行)",
hint="OOM kill → 给 clamav 容器加内存限制 512m → 1g;\nsegfault → 检查 ClamAV 版本兼容性",
evidence=ev,
run_by=run_by,
)
return _ok(
"R009",
f"ClamAV 容器稳定(restart_count={restart_count},无崩溃日志)",
run_by=run_by,
)
check_r009_clamav_crash._rule_id = "R009"
def check_r010_postgres_fatal(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R010"
return skip
cfg = get_diag_config()
logs = _docker_logs("meshsafe-postgres", cfg.docker_log_tail)
patterns = ["FATAL", "PANIC", "too many connections", "database is in recovery"]
hits = _grep_patterns(logs, patterns)
if hits:
return _crit(
"R010",
f"Postgres 出现严重错误(命中 {len(hits)} 行)",
hint="too many connections → 调大 max_connections;\nFATAL → 检查磁盘空间和 WAL",
evidence=hits[:8],
run_by=run_by,
)
return _ok("R010", "Postgres 日志无 FATAL/PANIC", run_by=run_by)
check_r010_postgres_fatal._rule_id = "R010"
def check_r011_redis_oom(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R011"
return skip
cfg = get_diag_config()
logs = _docker_logs("meshsafe-redis", cfg.docker_log_tail)
# 注意:不能用 "LOADING" — Redis 正常启动也会打 "Loading RDB / Done loading"
# 只匹配真正有问题的:加载卡死循环、OOM、panic、assertion
patterns = [
"OOM command not allowed",
"out of memory",
"LOADING loop",
"still loading",
"LOADING state",
"panic",
"Assertion",
]
hits = _grep_patterns(logs, patterns)
if hits:
return _crit(
"R011",
f"Redis 可能 OOM 或 panic(命中 {len(hits)} 行)",
hint="OOM → 调大 maxmemory 或清缓存;\nLOADING 循环 → 检查 RDB/AOF 恢复",
evidence=hits[:5],
run_by=run_by,
)
return _ok("R011", "Redis 日志无 OOM/panic", run_by=run_by)
check_r011_redis_oom._rule_id = "R011"
def check_r012_no_space(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R012"
return skip
cfg = get_diag_config()
all_patterns = ["No space left on device", "ENOSPC", "disk quota exceeded"]
containers = [
"meshsafe-api", "meshsafe-clamav", "meshsafe-postgres",
"meshsafe-redis", "meshsafe-nginx",
]
all_hits: List[str] = []
for c in containers:
logs = _docker_logs(c, cfg.docker_log_tail)
hits = _grep_patterns(logs, all_patterns, max_lines=5)
for h in hits:
all_hits.append(f"[{c}] {h}")
if len(all_hits) >= 10:
break
if all_hits:
return _crit(
"R012",
"容器日志出现磁盘空间不足(严重!)",
hint="立即清理磁盘,否则扫描会持续失败",
evidence=all_hits[:10],
run_by=run_by,
)
return _ok("R012", "所有容器日志无磁盘空间错误", run_by=run_by)
check_r012_no_space._rule_id = "R012"
def check_r013_nginx_upstream(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R013"
return skip
cfg = get_diag_config()
logs = _docker_logs("meshsafe-nginx", cfg.docker_log_tail)
# 只匹配真正的 upstream 故障。
# 注意 nginx 正常日志里有 "upstream response is buffered to a temporary file",
# 那是响应太大被缓冲的正常行为,绝不能在这里误伤。
patterns = [
"Connection refused",
"upstream timed out",
"upstream prematurely closed",
"no live upstreams",
"connect() failed",
"502 Bad Gateway",
"upstream server temporarily disabled",
]
hits = _grep_patterns(logs, patterns)
if hits:
return _warn(
"R013",
f"Nginx upstream 异常(命中 {len(hits)} 行)",
hint="api 容器可能临时不可用;检查 api 健康状态",
evidence=hits[:5],
run_by=run_by,
)
return _ok("R013", "Nginx 无 upstream 异常", run_by=run_by)
check_r013_nginx_upstream._rule_id = "R013"
def check_r014_api_connection(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R014"
return skip
cfg = get_diag_config()
logs = _docker_logs("meshsafe-api", cfg.docker_log_tail)
patterns = ["Redis connection refused", "Postgres connection refused",
"ConnectionError", "OperationalError", "can't connect"]
hits = _grep_patterns(logs, patterns)
if hits:
return _crit(
"R014",
f"API 与依赖服务连接异常(命中 {len(hits)} 行)",
hint="Redis/Postgres 网络断了?检查 docker 网络和依赖容器健康",
evidence=hits[:5],
run_by=run_by,
)
return _ok("R014", "API 与依赖连接正常", run_by=run_by)
check_r014_api_connection._rule_id = "R014"
# ======================================================================
# R015: 容器重启次数
# ======================================================================
def check_r015_container_restart(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
skip = _check_docker_available(engine)
if skip:
skip.id = "R015"
return skip
containers = [
"meshsafe-api", "meshsafe-clamav", "meshsafe-postgres",
"meshsafe-redis", "meshsafe-nginx",
]
now_ts = time.time()
bj = datetime.now(timezone(timedelta(hours=8)))
restart_events_now: List[str] = [] # 本轮检测到的新事件(写进 evidence)
new_restart_containers: List[str] = [] # 有新重启事件的容器名
for c in containers:
info = _docker_inspect(c)
if not info:
continue
cur_restart = info.get("restart_count", 0)
cur_started = info.get("started_at", "") or ""
old = _CONTAINER_LAST_STATE.get(c)
restart_changed = False
started_changed = False
if old is not None:
# 第一次跑没有 old,不算事件,只建缓存
restart_changed = cur_restart > old.get("restart_count", -1)
started_changed = cur_started != old.get("started_at", "")
if restart_changed or started_changed:
trigger = "restart_count_increased" if restart_changed else "started_at_changed"
uptime_before_s = 0
try:
uptime_before_s = int(now_ts - old.get("checked_at", now_ts))
except Exception:
uptime_before_s = 0
m, uptime_before_s = divmod(uptime_before_s, 60)
h, m = divmod(m, 60)
uptime_before_str = (
f"{h}h {m}m" if h > 0 else f"{m}m" if m > 0 else "刚启动"
)
event_line = (
f"{bj.strftime('%H:%M:%S')} {c} "
f"restart_count {old.get('restart_count','?')} → {cur_restart} "
f"uptime_before={uptime_before_str} "
f"trigger={trigger}"
)
restart_events_now.append(event_line)
new_restart_containers.append(c)
# 持久化重启事件
try:
from app.diagnostic.storage import (
append_restart_event, snapshot_on_restart,
)
append_restart_event(
container=c,
restart_before=old.get("restart_count", 0),
restart_after=cur_restart,
uptime_before=uptime_before_str,
uptime_before_seconds=now_ts - old.get("checked_at", now_ts),
trigger=trigger,
)
# 抓重启时的日志片段
try:
snapshot_on_restart(c, tail=300)
except Exception:
pass
except Exception:
pass
# 更新缓存为当前状态
_CONTAINER_LAST_STATE[c] = {
"restart_count": cur_restart,
"started_at": cur_started,
"checked_at": now_ts,
}
# ---------- 判定 severity ----------
# 1. 有新重启事件 → medium(哪怕 restart_count 还没超 3)
# 2. restart_count > 3 → crit
# 3. 否则 → ok
crit_list: List[str] = []
all_status: List[str] = []
for c in containers:
info = _docker_inspect(c)
if info:
r = info.get("restart_count", 0)
all_status.append(f"{c}: restart={r}")
if r > 3:
crit_list.append(f"{c} (restart_count={r})")
if crit_list:
# 严重:累积重启次数过多
combined_evidence = list(restart_events_now) + [f"⚠️ {x}" for x in crit_list]
return _crit(
"R015",
f"以下容器不稳定(restart_count > 3): {', '.join(crit_list)}",
hint="检查对应容器日志找 crash 根因(OOM/segmentation/配置错误)",
evidence=combined_evidence + all_status,
run_by=run_by,
)
if new_restart_containers:
# 中等:本轮检测到新重启事件(但累积次数还没超 3)
return _warn(
"R015",
f"本轮检测到容器重启事件: {', '.join(new_restart_containers)}",
hint="开发/运维可能执行了 docker restart,或容器健康检查触发了自动重启。"
"日志片段已自动归档到 diagnostics_logs/*-restart-*.log",
evidence=restart_events_now + all_status,
run_by=run_by,
)
return _ok(
"R015",
"所有容器稳定(restart_count 均 ≤ 3,本轮无新重启事件)",
evidence=all_status,
run_by=run_by,
)
check_r015_container_restart._rule_id = "R015"
# ======================================================================
# R016: 容器重启历史汇总(读持久化 restart_events.jsonl)
# ======================================================================
def check_r016_restart_history(engine: DiagnosticEngine, run_by: str = "") -> RuleResult:
"""读 restart_events.jsonl + compose_state,给出过去 N 天每个容器重启次数汇总。
和 R015 的区别:
R015 = 当前状态检测(restart_count 阈值 + 本轮变化)
R016 = 历史回顾(持久化事件日志聚合)
"""
skip = _check_docker_available(engine)
if skip:
skip.id = "R016"
return skip
cfg = get_diag_config()
# 1. 读持久化重启事件
events: List[Dict[str, Any]] = []
try:
from app.diagnostic.storage import load_restart_events
if cfg.storage_enabled:
events = load_restart_events(cfg.history_retention_days)
except Exception:
events = []
# 2. 按容器聚合
by_container: Dict[str, List[Dict[str, Any]]] = {}
for ev in events:
c = ev.get("container", "?")
by_container.setdefault(c, []).append(ev)
# 3. 取当前 restart_count(compose_state)
containers = [
"meshsafe-api", "meshsafe-clamav", "meshsafe-postgres",
"meshsafe-redis", "meshsafe-nginx",
]
current_counts: Dict[str, int] = {}
for c in containers:
info = _docker_inspect(c)
if info:
current_counts[c] = info.get("restart_count", 0)
# 4. 构建 evidence(每个容器一行汇总)
evidence: List[str] = []
total_events = 0
problem_containers: List[str] = []
for c in containers:
hist_count = len(by_container.get(c, []))
cur_count = current_counts.get(c, 0)
total_events += hist_count
line = f"{c}: 历史重启事件={hist_count} 当前restart_count={cur_count}"
if hist_count > 0:
last_ev = by_container[c][-1]
line += f" 最近事件={last_ev.get('ts')}"
evidence.append(line)
if hist_count >= 3:
problem_containers.append(f"{c}({hist_count}次)")
# 5. 判定
if total_events == 0:
return _ok(
"R016",
f"过去 {cfg.history_retention_days} 天内,无容器重启事件记录",
evidence=evidence,
run_by=run_by,
)
if problem_containers:
return _warn(
"R016",
f"过去 {cfg.history_retention_days} 天内,以下容器重启较频繁: "
f"{', '.join(problem_containers)}",
hint="开发/运维可能执行了多次 docker restart,"
"或容器健康检查/初始化阶段触发重启。"
"详见 restart_events.json 和 diagnostics_logs/*-restart-*.log",
evidence=evidence,
run_by=run_by,
)
# 有事件但不频繁
return _ok(
"R016",
f"过去 {cfg.history_retention_days} 天内共 {total_events} 次重启事件(均不频繁)",
evidence=evidence,
run_by=run_by,
)
check_r016_restart_history._rule_id = "R016"

268
app/diagnostic/scheduler.py Normal file
View File

@@ -0,0 +1,268 @@
"""三层定时器循环。
在 FastAPI startup 事件中用 asyncio.create_task() 启动。
定时器在后台跑,每次迭代独立 try/except + timeout,不会卡死主 event loop。
"""
from __future__ import annotations
import asyncio
import functools
import logging
import os
import subprocess
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from app.config import get_settings
from app.db import SessionLocal
from app.diagnostic.config import DiagConfig, get_diag_config
from app.diagnostic.engine import DiagnosticEngine, RuleResult
from app.services.scanner import Scanner
logger = logging.getLogger(__name__)
# 诊断套件全局运行时状态(单例 dict)
_DIAG_STATE: Dict[str, Any] = {
"enabled": False,
"docker_status": "none",
"docker_info": {},
"started_at": None,
"rules_cache": {}, # {rule_id: {result, ts, run_by}}
"latest": {"rules": [], "generated_at": None, "docker_status": "none"},
}
# 后台引用(防止 GC 收走)
_TIMER_TASKS: List[asyncio.Task] = []
# ======================================================================
# 外部访问入口
# ======================================================================
def get_diag_state() -> Dict[str, Any]:
"""获取诊断套件运行时状态(给页面/API 查询用)。"""
cache = {k: {"result": v["result"].to_dict() if isinstance(v.get("result"), RuleResult) else v.get("result"),
"ts": v.get("ts"), "run_by": v.get("run_by")}
for k, v in _DIAG_STATE["rules_cache"].items()}
return {
"enabled": _DIAG_STATE["enabled"],
"docker_status": _DIAG_STATE["docker_status"],
"docker_info": _DIAG_STATE["docker_info"],
"started_at": _DIAG_STATE["started_at"],
"rules_cache": cache,
"latest": _DIAG_STATE["latest"],
}
# ======================================================================
# 后台入口(在 app.main.startup 中调用)
# ======================================================================
def start_timers(docker_status: str, docker_info: Dict[str, Any]) -> None:
"""启动三个定时器循环(后台 daemon task)。"""
cfg = get_diag_config()
_DIAG_STATE["enabled"] = cfg.enabled
_DIAG_STATE["docker_status"] = docker_status
_DIAG_STATE["docker_info"] = docker_info
_DIAG_STATE["started_at"] = datetime.now(timezone.utc).isoformat()
if not cfg.enabled:
logger.info("MeshSafe Diagnostics: disabled")
return
loop = asyncio.get_event_loop()
if cfg.error_scan_interval > 0:
_TIMER_TASKS.append(loop.create_task(
_timer_loop("error_scan", cfg.error_scan_interval,
lambda eng: eng.run_timer_error_scan(),
docker_status, cfg.docker_log_tail),
name="diag-error-scan",
))
logger.info("诊断定时器启动: error_scan (%ss)", cfg.error_scan_interval)
if cfg.db_aggregate_interval > 0:
_TIMER_TASKS.append(loop.create_task(
_timer_loop("db_aggregate", cfg.db_aggregate_interval,
lambda eng: eng.run_timer_db_aggregate(),
docker_status, cfg.docker_log_tail),
name="diag-db-aggregate",
))
logger.info("诊断定时器启动: db_aggregate (%ss)", cfg.db_aggregate_interval)
if cfg.container_check_interval > 0:
_TIMER_TASKS.append(loop.create_task(
_timer_loop("container_check", cfg.container_check_interval,
lambda eng: eng.run_timer_container(),
docker_status, cfg.docker_log_tail),
name="diag-container-check",
))
logger.info("诊断定时器启动: container_check (%ss)", cfg.container_check_interval)
# ======================================================================
# 定时器循环(async)
# ======================================================================
async def _timer_loop(
name: str,
interval: int,
runner_fn, # 接受 DiagnosticEngine 返回 List[RuleResult]
docker_status: str,
docker_log_tail: int,
) -> None:
"""通用定时器循环。每次迭代独立 try/except + 超时保护。"""
# 等一个 interval 再开始第一次(避免 startup 时 DB 还没 ready)
await asyncio.sleep(min(interval, 15))
while True:
try:
# 分布式锁(时间槽位):多 worker 部署时,每个时间槽只有
# 一个 worker 能拿到锁并执行,保证执行频率精确等于 interval。
if not _acquire_timer_lock(name, interval):
logger.debug("诊断定时器 %s 本轮跳过(其他 worker 持锁)", name)
await asyncio.sleep(interval)
continue
results = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(
None,
lambda: _run_sync(name, runner_fn, docker_status, docker_log_tail),
),
timeout=30.0,
)
_cache_results(name, results, docker_status)
logger.debug("诊断定时器 %s 跑完: %d 条规则", name, len(results))
except asyncio.CancelledError:
logger.info("诊断定时器 %s 被取消", name)
break
except asyncio.TimeoutError:
logger.warning("诊断定时器 %s 超时(30s),本轮跳过", name)
except Exception as e: # noqa: BLE001
logger.exception("诊断定时器 %s 异常: %s,%ss 后重试", name, e, interval)
await asyncio.sleep(interval)
# ======================================================================
# 定时器分布式锁(Redis)
#
# 多 worker 部署(UVICORN_WORKERS > 1)时,每个 worker 都会启动定时器,
# 导致:同一轮规则被跑 N 次、历史数据量 ×N、并发写 JSONL 造成文件损坏。
# 用 Redis SET NX 抢锁,保证同一时刻只有一个 worker 在跑。
# ======================================================================
_TIMER_LOCK_PREFIX = "meshsafe:diag:timer_lock:"
@functools.lru_cache(maxsize=1)
def _lock_redis():
"""定时器锁专用 Redis 客户端(进程内复用,避免每次调用重建连接)。"""
import redis # type: ignore
from app.config import get_settings
return redis.from_url(
get_settings().redis_url, decode_responses=True, socket_timeout=3
)
def _acquire_timer_lock(timer_name: str, interval: int) -> bool:
"""尝试获取定时器锁(时间槽位设计)。
key 里带上当前时间槽位号:``meshsafe:diag:timer_lock:<timer>:<slot>``,
slot = 当前时间 // interval。这样无论多少 worker、谁先谁后,
同一个槽位内最多只有一个 worker 能拿到锁并执行,
下一个槽位自然放行 —— 执行频率精确等于 interval,不多不少。
Redis 不可用时返回 True(降级为单 worker 行为,不阻塞主流程)。
"""
try:
client = _lock_redis()
slot = int(time.time() // interval)
key = f"{_TIMER_LOCK_PREFIX}{timer_name}:{slot}"
# EX 只做兜底(槽位 key 不会互相干扰),设 2 倍 interval 足够
return bool(client.set(key, "1", nx=True, ex=interval * 2))
except Exception as e: # noqa: BLE001
# Redis 不可用 → 不加锁(单机单 worker 场景行为不变)
logger.debug("定时器锁获取失败,降级为无锁执行: %s", e)
return True
def _run_sync(
name: str,
runner_fn,
docker_status: str,
docker_log_tail: int,
) -> List[RuleResult]:
"""在线程池里同步跑规则(避免阻塞 event loop)。"""
db = SessionLocal()
try:
# 延迟 import 避免循环依赖
from app.main import scanner, policy_store
eng = DiagnosticEngine(
db=db, scanner=scanner, policy_store=policy_store,
docker_status=docker_status, docker_log_tail=docker_log_tail,
)
results = runner_fn(eng)
return results
finally:
db.close()
def _cache_results(
timer_name: str,
results: List[RuleResult],
docker_status: str,
) -> None:
"""把本轮规则结果写入内存缓存(dict)+ 持久化存储。"""
now = time.time()
for r in results:
_DIAG_STATE["rules_cache"][r.id] = {
"result": r,
"ts": now,
"run_by": r.run_by or f"timer:{timer_name}",
}
# 构建 latest 快照(合并所有缓存 + 本轮结果)
all_results = []
seen = set()
for rid, entry in _DIAG_STATE["rules_cache"].items():
if rid not in seen and entry.get("result"):
all_results.append(entry["result"])
seen.add(rid)
_DIAG_STATE["latest"] = {
"rules": sorted(all_results, key=lambda x: x.id),
"generated_at": now,
"docker_status": docker_status,
}
# ---------- 持久化 ----------
try:
from app.diagnostic.config import get_diag_config
from app.diagnostic.storage import append_rule_results
cfg = get_diag_config()
if cfg.storage_enabled:
append_rule_results(results, timer_name)
# 可选:定时器也存 docker 日志快照(省磁盘所以用较小 tail)
if cfg.log_snapshot_on_timer and docker_status != "none":
from app.diagnostic.storage import snapshot_container_logs
containers = [
"meshsafe-api", "meshsafe-clamav", "meshsafe-postgres",
"meshsafe-redis", "meshsafe-nginx",
]
# 不要每次都存 — 只在 container_check(5 分钟周期)里存一次
if timer_name == "container_check":
snapshot_container_logs(
containers, cfg.log_snapshot_tail,
filename_prefix="timer",
)
except Exception as e: # noqa: BLE001
logger.debug("诊断持久化跳过(非致命): %s", e)

455
app/diagnostic/storage.py Normal file
View File

@@ -0,0 +1,455 @@
"""诊断套件持久化存储模块。
职责:
- 定时器规则结果 → JSON Lines 追加(rules_history.jsonl)
- Docker 日志快照 → 存文件(logs/*.log)
- 自动清理过期数据(基于配置的 history_retention_days)
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from app.diagnostic.config import get_diag_config
logger = logging.getLogger(__name__)
def _storage_dir() -> Path:
"""获取存储根目录。"""
cfg = get_diag_config()
p = Path(cfg.storage_dir)
p.mkdir(parents=True, exist_ok=True)
return p
def _logs_dir() -> Path:
p = _storage_dir() / "logs"
p.mkdir(parents=True, exist_ok=True)
return p
def _rules_history_file() -> Path:
return _storage_dir() / "rules_history.jsonl"
# ======================================================================
# 跨进程文件锁写入
#
# 多 worker 部署时,每个 worker 都会跑诊断定时器。"a" 追加模式在多进程
# 下不安全——各进程独立维护文件偏移量,后写的会覆盖先写的内容,把一行
# JSON 从中间劈开,产出非法 UTF-8 字节,导致后续读取全部报错。
# 这里用 fcntl 文件锁保证同一时刻只有一个进程在写。
# ======================================================================
try:
import fcntl # type: ignore
_HAS_FCNTL = True
except ImportError: # pragma: no cover - Windows 平台
fcntl = None # type: ignore
_HAS_FCNTL = False
def _append_line_locked(path: Path, line: str) -> None:
"""向文件追加一行,跨进程互斥。
Linux 下用 fcntl.flock 排他锁;无 fcntl 时退化为进程内线程锁。
"""
payload = line + "\n"
with open(path, "a", encoding="utf-8") as fp:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_EX)
try:
fp.seek(0, os.SEEK_END)
fp.write(payload)
fp.flush()
os.fsync(fp.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
def _rewrite_file_locked(path: Path, lines: List[str]) -> None:
"""整文件重写(清理过期数据用),同样走文件锁。
写临时文件再原子替换,避免重写过程中其他进程读到半截内容。
"""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fp:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_EX)
try:
for line in lines:
fp.write(line + "\n")
fp.flush()
os.fsync(fp.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
os.replace(tmp, path)
# ======================================================================
# 规则历史:JSON Lines 追加
# ======================================================================
def append_rule_results(
rules: List[Any],
timer_name: str,
) -> None:
"""定时器每次跑完追加一行 JSONL。
每行格式::
{"ts": "2026-10-28T13:12:14", "timer": "error_scan",
"docker_status": "full",
"rules": [{"id":"R001","severity":"ok","message":"...","run_at":"...","run_by":"timer:error_scan",...}]}
"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return
try:
# 北京时间
bj = timezone(timedelta(hours=8))
entry: Dict[str, Any] = {
"ts": datetime.now(bj).strftime("%Y-%m-%d %H:%M:%S"),
"timer": timer_name,
"docker_status": None,
"rules": [
r.to_dict() if hasattr(r, "to_dict") else dict(r)
for r in rules
],
}
f = _rules_history_file()
_append_line_locked(f, json.dumps(entry, ensure_ascii=False))
# 顺带清理过期
_trim_history()
except Exception as e: # noqa: BLE001
logger.warning("诊断持久化写入失败: %s", e)
def load_rule_history(days: Optional[int] = None) -> List[Dict[str, Any]]:
"""读取规则历史 JSONL,返回最近 N 天的行。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
days = days or cfg.history_retention_days
f = _rules_history_file()
if not f.exists():
return []
cutoff = datetime.now() - timedelta(days=days)
results: List[Dict[str, Any]] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# 按 ts 判断是否过期
ts_str = obj.get("ts", "")
try:
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
results.append(obj)
except ValueError:
# 时间格式不对就不过滤,保留
results.append(obj)
except Exception as e: # noqa: BLE001
logger.warning("诊断历史读取失败: %s", e)
return results
def _trim_history() -> None:
"""清理过期的规则历史行(保留文件头)。"""
cfg = get_diag_config()
days = cfg.history_retention_days
f = _rules_history_file()
if not f.exists():
return
cutoff = datetime.now() - timedelta(days=days)
kept: List[str] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
total = 0
for line in fp:
total += 1
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
ts_str = obj.get("ts", "")
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
kept.append(line)
except Exception:
kept.append(line) # 格式异常的保留
# 如果删了一些(不是全部),就重写
if kept and len(kept) < total:
_rewrite_file_locked(f, kept)
except Exception as e: # noqa: BLE001
logger.warning("诊断历史清理失败: %s", e)
# ======================================================================
# Docker 日志快照
# ======================================================================
def snapshot_container_logs(
containers: List[str],
tail: int,
filename_prefix: Optional[str] = None,
) -> List[Path]:
"""把各容器当前 docker logs --tail N 存一份文件。
文件名: {container}-{prefix}{timestamp}.log
prefix 可选(如 timer 存的加前缀 timer-)
返回写入的文件列表。
"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
from app.diagnostic.docker_probe import get_client
client = get_client()
if client is None:
return []
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
prefix = f"{filename_prefix}-" if filename_prefix else ""
out: List[Path] = []
for name in containers:
try:
c = client.containers.get(name)
raw = c.logs(tail=tail)
text = (
raw.decode("utf-8", errors="replace")
if isinstance(raw, (bytes, bytearray))
else str(raw)
)
fname = f"{name}-{prefix}{ts}.log"
fpath = _logs_dir() / fname
fpath.write_text(text, encoding="utf-8")
out.append(fpath)
except Exception as e: # noqa: BLE001
logger.warning("容器 %s 日志快照失败: %s", name, e)
# 顺带清理过期日志
_trim_logs()
return out
def load_log_snapshots(containers: Optional[List[str]] = None) -> Dict[str, List[Path]]:
"""读 logs/ 目录,返回 {container_name: [path, path, ...]}。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return {}
cutoff = datetime.now() - timedelta(days=cfg.history_retention_days)
result: Dict[str, List[Path]] = {}
for f in _logs_dir().glob("*.log"):
stem = f.stem # 去掉 .log
# 文件名格式: meshsafe-api-timer-20261028-1312 或 meshsafe-api-20261028-1312
# 容器名是 stem 里第一个 - 前面的部分... 其实容器名本身也可能带 -
# 简化:直接用 stem 整体当 key(导出时按需匹配)
# 但我们需要按容器分组,所以用 glob 前缀匹配
if containers:
matched = None
for c in containers:
if stem.startswith(c + "-"):
matched = c
break
if matched is None:
continue
container_key = matched
else:
# 没指定容器,尝试从文件名推断
parts = stem.split("-")
container_key = parts[0] if parts else stem
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime)
if mtime >= cutoff:
result.setdefault(container_key, []).append(f)
except Exception:
result.setdefault(container_key, []).append(f)
# 按 mtime 排序(新的在前)
for k in result:
result[k].sort(key=lambda p: p.stat().st_mtime, reverse=True)
return result
def _trim_logs() -> None:
"""清理过期的日志快照文件。"""
cfg = get_diag_config()
cutoff = datetime.now() - timedelta(days=cfg.history_retention_days)
try:
for f in _logs_dir().glob("*.log"):
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime)
if mtime < cutoff:
f.unlink()
except FileNotFoundError:
pass
except Exception as e: # noqa: BLE001
logger.warning("日志快照清理失败: %s", e)
# ======================================================================
# 容器重启事件追踪
# ======================================================================
def _restart_events_file() -> Path:
return _storage_dir() / "restart_events.jsonl"
def append_restart_event(
container: str,
restart_before: int,
restart_after: int,
uptime_before: str = "",
uptime_before_seconds: float = 0,
trigger: str = "restart_count_increased",
) -> None:
"""记录一条容器重启事件到 restart_events.jsonl。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return
try:
bj = timezone(timedelta(hours=8))
entry = {
"ts": datetime.now(bj).strftime("%Y-%m-%d %H:%M:%S"),
"container": container,
"restart_before": restart_before,
"restart_after": restart_after,
"uptime_before": uptime_before,
"uptime_before_seconds": int(uptime_before_seconds),
"trigger": trigger,
}
f = _restart_events_file()
_append_line_locked(f, json.dumps(entry, ensure_ascii=False))
# 清理过期事件
_trim_restart_events()
except Exception as e: # noqa: BLE001
logger.warning("重启事件写入失败: %s", e)
def snapshot_on_restart(container: str, tail: int = 300) -> Optional[Path]:
"""容器刚重启后,抓一份当前 docker logs 片段存到 logs/*-restart-*.log。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return None
try:
from app.diagnostic.docker_probe import get_client
client = get_client()
if client is None:
return None
c = client.containers.get(container)
raw = c.logs(tail=tail)
text = (
raw.decode("utf-8", errors="replace")
if isinstance(raw, (bytes, bytearray))
else str(raw)
)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
fname = f"{container}-restart-{ts}.log"
fpath = _logs_dir() / fname
fpath.write_text(text, encoding="utf-8")
return fpath
except Exception as e: # noqa: BLE001
logger.warning("重启日志快照失败 %s: %s", container, e)
return None
def load_restart_events(days: Optional[int] = None) -> List[Dict[str, Any]]:
"""读取重启事件 JSONL,返回最近 N 天的行。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
days = days or cfg.history_retention_days
f = _restart_events_file()
if not f.exists():
return []
cutoff = datetime.now() - timedelta(days=days)
results: List[Dict[str, Any]] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ts_str = obj.get("ts", "")
try:
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
results.append(obj)
except ValueError:
results.append(obj)
except Exception as e: # noqa: BLE001
logger.warning("重启事件读取失败: %s", e)
return results
def _trim_restart_events() -> None:
"""清理过期的重启事件行。"""
cfg = get_diag_config()
days = cfg.history_retention_days
f = _restart_events_file()
if not f.exists():
return
cutoff = datetime.now() - timedelta(days=days)
kept: List[str] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
total = 0
for line in fp:
total += 1
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
ts_str = obj.get("ts", "")
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
kept.append(line)
except Exception:
kept.append(line)
if kept and len(kept) < total:
_rewrite_file_locked(f, kept)
except Exception as e: # noqa: BLE001
logger.warning("重启事件清理失败: %s", e)

1
app/engines/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""引擎包。"""

123
app/engines/base.py Normal file
View File

@@ -0,0 +1,123 @@
"""检测引擎公共数据结构与基类。"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
class Severity(str, Enum):
INFO = "info"
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Layer(str, Enum):
L0 = "L0"
L1 = "L1"
L2 = "L2"
L2B = "L2B"
L3 = "L3"
@dataclass
class Finding:
"""单条检测信号。"""
engine: str
layer: str
severity: str
rule: str
detail: str = ""
extracted_path: str = ""
weight: float = 0.0
meta: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"engine": self.engine,
"layer": self.layer,
"severity": self.severity,
"rule": self.rule,
"detail": self.detail,
"extracted_path": self.extracted_path,
"weight": self.weight,
"meta": self.meta,
}
@dataclass
class EngineResult:
"""单个引擎对单个文件的检测结果。"""
engine: str
ok: bool = True
findings: List[Finding] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
error: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"engine": self.engine,
"ok": self.ok,
"findings": [f.to_dict() for f in self.findings],
"meta": self.meta,
"error": self.error,
}
@dataclass
class ScanTarget:
"""待检测文件。"""
path: str
filename: str
declared_ext: str = ""
logical_path: str = ""
depth: int = 0
is_extracted: bool = False
class Engine:
"""引擎基类。
子类实现 ``scan`` 方法,捕获所有异常并转换为 ``EngineResult.error``,
保证单个引擎故障不会导致整个扫描任务失败。
"""
name: str = "base"
layer: str = "L0"
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
raise NotImplementedError
def safe_scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
try:
return self.scan(target, ctx)
except Exception as exc: # noqa: BLE001
return EngineResult(engine=self.name, ok=False, error=f"{type(exc).__name__}: {exc}")
def health(self) -> Dict[str, Any]:
"""引擎健康状态。子类可覆盖以提供更丰富的探测(如 ClamAV/PING)。
返回结构约定(控制台 dashboard 模板依赖此 schema):
{
"engine": "clamav", # 引擎名(= self.name)
"version": "ClamAV 1.0.x", # 版本描述
"ok": True/False, # 是否可用
"status": "ok"|"error"|"degraded",
"error": None | "...",
"info": None | "...",
}
"""
return {
"engine": self.name,
"version": "builtin",
"ok": True,
"status": "ok",
"error": None,
"info": None,
}

302
app/engines/binary.py Normal file
View File

@@ -0,0 +1,302 @@
"""L2b YARA 规则与二进制分析引擎。
包含:
- YARA 规则匹配(规则库从 ``rules/`` 目录加载,支持热加载)
- PE 文件结构分析(pefile):节区异常、导入表、编译信息、签名
- 可执行文件能力识别(capa,若已安装)
"""
from __future__ import annotations
import os
import threading
from typing import Any, Dict, List, Optional
from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
BINARY_EXTS = {".exe", ".dll", ".sys", ".scr", ".com", ".cpl", ".ocx", ".msi", ".apk", ".jar", ".so", ".bin", ".elf"}
# ---------- 模块级全局 YARA 规则单例 ----------
# 所有 YaraEngine 实例共享同一个编译结果,避免重复 compile
# rules_dir 不同时(极少见)才分叉加载
_YARA_GLOBAL: Dict[str, Dict[str, Any]] = {}
_YARA_LOCK = threading.Lock()
def _get_global_yara(rules_dir: str) -> Optional[Any]:
"""获取全局共享的 YARA Rules 对象(自动按目录 mtime 重载)。"""
with _YARA_LOCK:
cached = _YARA_GLOBAL.get(rules_dir)
import yara as _yr
newest = 0.0
files: List[str] = []
if os.path.isdir(rules_dir):
for name in sorted(os.listdir(rules_dir)):
if name.endswith((".yar", ".yara")):
path = os.path.join(rules_dir, name)
files.append(path)
newest = max(newest, os.path.getmtime(path))
if cached and cached.get("mtime") == newest:
return cached.get("rules")
if not files:
_YARA_GLOBAL[rules_dir] = {"rules": None, "mtime": newest}
return None
try:
compiled = _yr.compile(filepaths={os.path.basename(p): p for p in files})
except Exception:
_YARA_GLOBAL[rules_dir] = {"rules": None, "mtime": newest}
return None
_YARA_GLOBAL[rules_dir] = {"rules": compiled, "mtime": newest}
return compiled
def _discover_rules_dir() -> str:
"""自动探测 YARA 规则目录(优先环境变量 > /app/rules > 项目根 rules/)。"""
env_dir = os.environ.get("MESHSAFE_RULES_DIR", "").strip()
if env_dir and os.path.isdir(env_dir):
return env_dir
for candidate in ("/app/rules", "rules", "./rules", "../rules"):
if os.path.isdir(candidate):
return os.path.abspath(candidate)
return env_dir or "/app/rules"
class YaraEngine(Engine):
"""YARA 规则匹配引擎(使用全局共享的编译结果)。"""
name = "yara"
layer = "L2B"
def __init__(self, rules_dir: str | None = None) -> None:
# 显式传入时才用传入值,否则走自动探测
self.rules_dir = rules_dir if rules_dir else _discover_rules_dir()
def _load_rules(self) -> Any:
"""委托给全局单例。"""
try:
import yara # noqa: F401 — 只是探测可用性
except Exception: # noqa: BLE001
return None
return _get_global_yara(self.rules_dir)
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
result = EngineResult(engine=self.name)
rules = self._load_rules()
if rules is None:
result.ok = False
result.error = "YARA 规则库不可用或编译失败"
return result
try:
matches = rules.match(target.path)
except Exception as exc: # noqa: BLE001
result.ok = False
result.error = f"YARA 匹配失败:{exc}"
return result
for match in matches:
severity, weight = _judge_yara_rule(match.rule, match.tags)
# 覆盖:如果 ctx 里的策略为该 YARA 规则定义了精细权重,用它
try:
policy = ctx.policy_engine.policy
yara_weights = getattr(policy, "yara_weights", {}) or {}
if match.rule in yara_weights:
weight = float(yara_weights[match.rule])
except Exception:
pass # ctx 没有 policy 字段时保持默认 weight
result.findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=severity,
rule=match.rule,
detail=f"YARA 规则 {match.rule} 命中(标签:{', '.join(match.tags) or '无'})",
weight=weight,
meta={"tags": list(match.tags)},
)
)
# rule_set_version 用全局 compiled 对象的 id(每次 reload 变)
try:
_r = _get_global_yara(self.rules_dir)
result.meta["rule_set_version"] = str(id(_r)) if _r else "unavailable"
except Exception:
result.meta["rule_set_version"] = "unknown"
return result
def health(self) -> Dict[str, Any]:
rules = self._load_rules()
ok = rules is not None
return {
"engine": self.name,
"version": "rules" if ok else "none",
"ok": ok,
"status": "ok" if ok else "degraded",
"error": None if ok else "YARA 规则库未加载",
"info": f"rules_dir={self.rules_dir}" if not ok else "rules loaded",
}
class BinaryEngine(Engine):
"""PE/二进制文件结构分析引擎。"""
name = "binary"
layer = "L2B"
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
result = EngineResult(engine=self.name)
ext = (target.declared_ext or "").lower()
if ext not in BINARY_EXTS:
return result
try:
import pefile
except Exception: # noqa: BLE001
result.ok = False
result.error = "pefile 未安装"
return result
try:
pe = pefile.PE(target.path, fast_load=False)
except Exception as exc: # noqa: BLE001
result.findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.MEDIUM.value,
rule="PeParseFailed",
detail=f"PE 结构解析失败:{exc}",
weight=10.0,
)
)
return result
try:
result.findings.extend(self._analyze(pe, target))
finally:
try:
pe.close()
except Exception: # noqa: BLE001
pass
return result
def _analyze(self, pe: Any, target: ScanTarget) -> List[Finding]:
findings: List[Finding] = []
# 1) 节区熵值:加密/加壳的节区熵接近 8
for section in getattr(pe, "sections", []) or []:
try:
name = section.Name.rstrip(b"\x00").decode("utf-8", errors="replace")
entropy = section.get_entropy()
except Exception: # noqa: BLE001
continue
if entropy >= 7.2:
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.MEDIUM.value,
rule="HighEntropySection",
detail=f"节区 {name} 熵值 {entropy:.2f},可能经过加密或加壳",
weight=20.0,
)
)
# 2) 可疑导入表
suspicious_imports = self._suspicious_imports(pe)
if suspicious_imports:
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.HIGH.value,
rule="SuspiciousImports",
detail=f"导入表包含高危 API:{', '.join(suspicious_imports[:8])}",
weight=45.0,
)
)
# 3) 无签名可执行文件
if not self._has_signature(pe):
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.MEDIUM.value,
rule="UnsignedExecutable",
detail="可执行文件未携带数字签名",
weight=20.0,
)
)
# 4) 编译时间戳异常(明显晚于其他节区时间,或为 1970/未来时间)
timestamp = getattr(pe, "FILE_HEADER", None)
if timestamp is not None:
try:
ts = int(timestamp.TimeDateStamp)
if ts == 0 or ts > 4102444800: # 2100 年之后
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.LOW.value,
rule="SuspiciousTimestamp",
detail="PE 编译时间戳异常,可能被伪造",
weight=10.0,
)
)
except Exception: # noqa: BLE001
pass
return findings
def _suspicious_imports(self, pe: Any) -> List[str]:
"""检测高危 API 导入。"""
suspicious = {
"VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread",
"NtUnmapViewOfSection", "SetWindowsHookEx", "OpenProcess",
"WinHttpOpen", "InternetOpenUrl", "URLDownloadToFile",
"CryptEncrypt", "AdjustTokenPrivileges", "CreateService",
"ShellExecute", "WinExec", "IsDebuggerPresent", "CheckRemoteDebuggerPresent",
}
found: List[str] = []
try:
for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []) or []:
for imp in entry.imports:
if imp.name:
name = imp.name.decode("utf-8", errors="replace")
if name in suspicious:
found.append(name)
except Exception: # noqa: BLE001
pass
return sorted(set(found))
def _has_signature(self, pe: Any) -> bool:
"""检查 PE 是否包含 Authenticode 签名目录。"""
try:
directory = pe.OPTIONAL_HEADER.DATA_DIRECTORY[4] # IMAGE_DIRECTORY_ENTRY_SECURITY
return bool(directory.VirtualAddress and directory.Size)
except Exception: # noqa: BLE001
return False
def _judge_yara_rule(rule: str, tags: List[str]) -> tuple[str, float]:
"""依据 YARA 规则名与标签判定严重度。"""
tag_set = {t.lower() for t in tags}
text = f"{rule} {' '.join(tags)}".lower()
if "critical" in tag_set or any(k in text for k in ("malware", "ransomware", "backdoor", "c2", "exploit")):
return Severity.CRITICAL.value, 85.0
if "high" in tag_set or any(k in text for k in ("trojan", "dropper", "obfusc", "shellcode")):
return Severity.HIGH.value, 60.0
if "medium" in tag_set or any(k in text for k in ("suspicious", "pua", "packer")):
return Severity.MEDIUM.value, 30.0
if "suspicious" in tag_set:
return Severity.MEDIUM.value, 30.0
return Severity.LOW.value, 10.0

284
app/engines/clamav.py Normal file
View File

@@ -0,0 +1,284 @@
"""ClamAV 检测引擎。
优先使用 clamd 常驻守护进程(TCP 或 Unix Socket)扫描,避免每次加载签名库。
重要设计决策:
- clamd 默认 MaxFileSize = 100MB,超过就会截断 INSTREAM 并关闭 socket
- 代码层需单独识别 Size limit,不当成引擎不可用(引擎活着,只是拒扫大文件)
- 这样 PolicyEngine 不会因单个大文件 fail_closed → 其他 5 引擎继续扫
"""
from __future__ import annotations
import errno
import socket
from typing import Any, Dict, List, Optional
from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
class _ClamdSizeLimit(Exception):
"""clamd 拒绝扫描超限文件时抛出的内部异常。
与普通引擎故障区分开——scan() 会捕获它并返回 ok=True + skipped 标记,
PolicyEngine 步骤 1(引擎可用性)因此不会触发 fail_closed。
"""
class ClamAVEngine(Engine):
"""ClamAV 静态查杀引擎。"""
name = "clamav"
layer = "L1"
def __init__(self, host: str, port: int, timeout: int = 120) -> None:
self.host = host
self.port = port
self.timeout = timeout
# ------------------------------------------------------------------ 对外
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
result = EngineResult(engine=self.name)
try:
raw = self._scan_with_clamd(target.path)
except _ClamdSizeLimit:
# 文件超过 ClamAV MaxFileSize(clamd.conf 可调,默认 1G)
# → 引擎只是拒扫这个大文件,不当成引擎故障
result.ok = True
result.meta["skipped"] = "size_limit"
result.meta["reason"] = "文件超过 ClamAV 大小限制已跳过"
return result
except Exception as exc: # noqa: BLE001
# 真·引擎不可用:连不上 / PING 超时 / daemon 挂了
result.ok = False
result.error = f"ClamAV 引擎不可用:{type(exc).__name__}: {exc}"
return result
result.meta.update(raw.get("meta", {}))
for item in raw.get("matches", []):
severity, weight = _judge_signature(item)
result.findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=severity,
rule=item,
detail=f"ClamAV 签名命中:{item}",
weight=weight,
)
)
return result
def health(self) -> Dict[str, Any]:
"""探测 clamd 存活状态与版本、签名库信息。"""
import re
from datetime import datetime, timezone
try:
resp = self._command("PING")
version = self._command("VERSION")
pong = resp.strip().upper() == "PONG"
sig_date_str = None
sig_days_old = None
stale_warning = False
short_version = version.strip() or "unknown"
if version:
parts = version.strip().split("/")
# ClamAV 1.5.x 的 VERSION 格式:ClamAV 1.5.4/28166/Tue Oct 27 14:24:12 2026
# 展示层只需要 "ClamAV 1.5.4",签名库日期单独解析
if len(parts) >= 2 and parts[1].isdigit():
short_version = parts[0].strip()
if len(parts) >= 3:
date_str = parts[-1].strip()
try:
sig_date_str = datetime.strptime(date_str, "%a %b %d %H:%M:%S %Y").isoformat()
sig_dt = datetime.strptime(date_str, "%a %b %d %H:%M:%S %Y").replace(tzinfo=timezone.utc)
sig_days_old = (datetime.now(timezone.utc) - sig_dt).days
stale_warning = sig_days_old > 7
except ValueError:
pass
base = {
"engine": self.name,
"version": short_version,
"ok": pong,
"status": "ok" if pong else "error",
"error": None if pong else "PING 未返回 PONG",
"info": None,
"signature_date": sig_date_str,
"signature_days_old": sig_days_old,
"stale_warning": stale_warning,
}
if stale_warning and pong:
base["status"] = "degraded"
return base
except Exception as exc: # noqa: BLE001
return {
"engine": self.name,
"version": "unknown",
"ok": False,
"status": "error",
"error": f"{type(exc).__name__}: {exc}",
"info": None,
}
# ------------------------------------------------------------------ 内部
def _command(self, command: str) -> str:
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.sendall(f"z{command}\0".encode("utf-8"))
sock.settimeout(self.timeout)
chunks = []
while True:
chunk = sock.recv(4096)
if not chunk:
break
chunks.append(chunk)
if chunk.endswith(b"\0"):
break
return b"".join(chunks).rstrip(b"\0").decode("utf-8", errors="replace")
def _scan_with_clamd(self, path: str) -> Dict[str, Any]:
"""执行 INSTREAM 扫描。
clamd 的 INSTREAM 协议:先发送 ``zINSTREAM\0``,随后按
``<4字节大端长度><数据块>`` 形式发送文件内容,最后以长度为 0 的块结束。
Size limit 识别:
- clamd 默认 MaxFileSize = 100MB(我们 clamd.conf 调到 1G)
- 超过限制时 clamd 会:
1) 在日志写 "INSTREAM: Size limit reached"
2) 关闭连接 → 我们 sock.sendall 抛 BrokenPipeError
3) 或直接在响应里返回 "stream: Size limit reached ERROR"
- 这两种情况统一转成 _ClamdSizeLimit,让上层 scan() 识别为 skipped
"""
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.settimeout(self.timeout)
sock.sendall(b"zINSTREAM\0")
try:
with open(path, "rb") as fh:
while True:
chunk = fh.read(1024 * 1024)
if not chunk:
break
sock.sendall(len(chunk).to_bytes(4, "big"))
sock.sendall(chunk)
except (BrokenPipeError, ConnectionResetError) as exc:
# clamd 因为 Size limit 提前关闭 socket
# 99% 的概率是 Size limit(也可能是其他问题,但都归为 skipped)
raise _ClamdSizeLimit(f"clamd closed connection during INSTREAM: {exc}") from exc
sock.sendall((0).to_bytes(4, "big"))
response = b""
while True:
try:
chunk = sock.recv(4096)
except socket.timeout as exc:
raise _ClamdSizeLimit(f"clamd INSTREAM response timeout: {exc}") from exc
if not chunk:
# clamd 没发任何响应就关了 socket → 大概率也是 Size limit
raise _ClamdSizeLimit("clamd closed socket before sending response (likely size limit)")
response += chunk
if b"\0" in response:
break
text = response.rstrip(b"\0").decode("utf-8", errors="replace").strip()
# clamd 可能在响应文本里显式报告 Size limit
if _is_size_limit_response(text):
raise _ClamdSizeLimit(f"clamd: {text}")
return _parse_clamd_response(text)
def scan_bytes(self, data: bytes) -> Dict[str, Any]:
"""直接扫描内存字节(用于小文件快速路径)。"""
with socket.create_connection((self.host, self.port), timeout=self.timeout) as sock:
sock.settimeout(self.timeout)
sock.sendall(b"zINSTREAM\0")
sock.sendall(len(data).to_bytes(4, "big"))
sock.sendall(data)
sock.sendall((0).to_bytes(4, "big"))
response = b""
while True:
chunk = sock.recv(4096)
if not chunk:
break
response += chunk
if b"\0" in response:
break
text = response.rstrip(b"\0").decode("utf-8", errors="replace").strip()
return _parse_clamd_response(text)
def _is_size_limit_response(text: str) -> bool:
"""检查 clamd 响应文本是否包含 Size limit 相关错误。
clamd 可能返回:
- "stream: Size limit reached ERROR"
- "stream: Heuristics.Limits.Exceeded FOUND"
- "Size limit reached, requested: ..., max: ..."
"""
upper = text.upper()
if "SIZE LIMIT" in upper:
return True
if "HEURISTICS.LIMITS.EXCEEDED" in upper:
return True
if "MAXSCANSIZE" in upper or "MAXFILESIZE" in upper:
return True
return False
def _parse_clamd_response(text: str) -> Dict[str, Any]:
"""解析 clamd 扫描响应。
正常未命中返回 ``stream: OK``;命中返回多行 ``stream: <签名名> FOUND``。
"""
matches: List[str] = []
meta: Dict[str, Any] = {}
for line in text.splitlines():
line = line.strip()
if not line:
continue
if ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if key == "stream":
if value.endswith("FOUND"):
name = value[: -len("FOUND")].strip()
matches.append(name)
elif value.upper() == "OK":
pass
elif value.upper() == "ERROR":
meta["error"] = value
continue
meta[key.lower().replace(" ", "_")] = value
elif line.upper().endswith("FOUND"):
matches.append(line[: -len("FOUND")].strip())
return {"matches": matches, "meta": meta}
# 签名名中的可信度关键词映射到严重度
_HIGH_KEYWORDS = ("ransom", "crypt", "trojan", "backdoor", "rootkit", "stealer", "keylog", "worm", "dropper", "ransomware")
_MEDIUM_KEYWORDS = ("generic", "heur", "packed", "obfusc", "suspect", "malware", "riskware", "virus")
_UNWANTED_KEYWORDS = ("PUA", "unwanted", "toolkit", "pup", "adware")
def _judge_signature(signature: str) -> tuple[str, float]:
"""依据签名名判定严重度与权重。"""
upper = signature.upper()
if any(k in upper for k in _HIGH_KEYWORDS):
return Severity.CRITICAL.value, 90.0
if any(k in upper for k in _UNWANTED_KEYWORDS):
return Severity.MEDIUM.value, 25.0
if any(k in upper for k in _MEDIUM_KEYWORDS):
return Severity.HIGH.value, 60.0
return Severity.HIGH.value, 55.0

1001
app/engines/documents.py Normal file

File diff suppressed because it is too large Load Diff

383
app/engines/extractor.py Normal file
View File

@@ -0,0 +1,383 @@
"""递归安全解包引擎。
安全约束(全部强制执行,不依赖调用方自觉):
- 解包深度上限
- 解压总大小上限与单文件大小上限
- 压缩比上限(防压缩炸弹)
- 路径穿越拦截(Zip Slip)
- 符号链接/硬链接逃逸拦截
- 解包目标固定在独立临时目录,文件名重写为安全形式(不直接使用归档内原始名)
解包出来的文件会加入待检测队列,实现"递归检测",避免只扫外层被绕过。
"""
from __future__ import annotations
import os
import re
import shutil
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass, field
from typing import Any, List, Optional
from app.engines.base import Finding, ScanTarget, Severity
ARCHIVE_EXTS = {".zip", ".jar", ".war", ".apk", ".epub", ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp"}
TAR_EXTS = {".tar", ".gz", ".tgz", ".bz2", ".xz", ".tbz2", ".txz"}
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._一-鿿-]+")
@dataclass
class ExtractionReport:
"""解包结果。"""
files: List[ScanTarget] = field(default_factory=list)
findings: List[Finding] = field(default_factory=list)
total_bytes: int = 0
truncated: bool = False
def to_dict(self) -> dict:
return {
"file_count": len(self.files),
"total_bytes": self.total_bytes,
"truncated": self.truncated,
}
class SafeExtractor:
"""递归安全解包器。"""
def __init__(self, ctx: Any) -> None:
self.ctx = ctx
self.settings = ctx.settings
self.engine_name = "extractor"
def extract(self, target: ScanTarget, workdir: str) -> ExtractionReport:
report = ExtractionReport()
ext = (target.declared_ext or "").lower()
if ext in ARCHIVE_EXTS or zipfile.is_zipfile(target.path):
self._extract_zip(target, workdir, report)
elif ext in TAR_EXTS or tarfile.is_tarfile(target.path):
self._extract_tar(target, workdir, report)
return report
# ------------------------------------------------------------------ 工具
def _sanitize_name(self, raw_name: str) -> str:
"""把归档内原始文件名改写为安全的相对路径。
去掉目录层级与危险字符,避免原始名称中的 ``..``、绝对路径、
Windows 保留设备名等造成问题。原始名称仍保留在逻辑路径中以便追溯。
"""
name = raw_name.replace("\\", "/")
base = os.path.basename(name)
base = _SAFE_NAME_RE.sub("_", base).strip("._")
if not base:
base = "unnamed"
# 防止 Windows 保留设备名
stem, dot, suffix = base.partition(".")
reserved = {
"con", "prn", "aux", "nul",
*(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10)),
}
if stem.lower() in reserved:
stem = f"_{stem}"
return f"{stem}{dot}{suffix}" if dot else stem
def _target_dir(self, workdir: str, target: ScanTarget) -> str:
"""为每个文件分配独立的解包子目录。"""
digest = abs(hash(target.logical_path or target.filename)) % 100000
path = os.path.join(workdir, f"ex_{target.depth}_{digest}")
os.makedirs(path, exist_ok=True)
return path
def _budget_exhausted(self, report: ExtractionReport) -> bool:
return report.total_bytes >= self.settings.max_extract_total_bytes
def _record_limit(self, report: ExtractionReport, limit: int = 5000) -> bool:
if len(report.files) >= limit:
report.truncated = True
return True
return False
def _add_finding(
self,
report: ExtractionReport,
severity: str,
rule: str,
detail: str,
weight: float = 0.0,
) -> None:
report.findings.append(
Finding(
engine=self.engine_name,
layer="L0",
severity=severity,
rule=rule,
detail=detail,
weight=weight,
)
)
# ------------------------------------------------------------------ ZIP
def _extract_zip(self, target: ScanTarget, workdir: str, report: ExtractionReport) -> None:
out_dir = self._target_dir(workdir, target)
max_ratio = self.settings.max_extract_ratio
max_total = self.settings.max_extract_total_bytes
max_depth = self.settings.max_extract_depth
try:
with zipfile.ZipFile(target.path) as zf:
infos = zf.infolist()
for info in infos:
if target.depth + 1 > max_depth:
self._add_finding(
report,
Severity.MEDIUM.value,
"ExtractDepthExceeded",
f"嵌套层级超过上限 {max_depth},深层内容未展开",
15.0,
)
report.truncated = True
break
if self._budget_exhausted(report) or self._record_limit(report):
self._add_finding(
report,
Severity.MEDIUM.value,
"ExtractBudgetExceeded",
"解包数量或体积达到上限,后续内容未展开",
10.0,
)
break
if info.is_dir():
continue
name = info.filename
if _is_traversal(name):
self._add_finding(
report,
Severity.CRITICAL.value,
"PathTraversalBlocked",
f"已拦截路径穿越条目:{name}",
50.0,
)
continue
# 压缩炸弹判定:按声明大小与实际膨胀比
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > max_ratio:
self._add_finding(
report,
Severity.HIGH.value,
"ArchiveBombBlocked",
f"成员 {name} 压缩比 {ratio:.1f}:1 超过阈值,已跳过",
35.0,
)
continue
if info.file_size > self.settings.max_upload_bytes:
self._add_finding(
report,
Severity.MEDIUM.value,
"MemberTooLarge",
f"成员 {name} 大小超过单文件上限,已跳过",
10.0,
)
continue
safe_name = self._sanitize_name(name)
dest = os.path.join(out_dir, safe_name)
try:
with zf.open(info) as src, open(dest, "wb") as out:
written = 0
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > self.settings.max_upload_bytes:
out.close()
os.remove(dest)
self._add_finding(
report,
Severity.MEDIUM.value,
"MemberTooLarge",
f"成员 {name} 实际解压超限,已丢弃",
10.0,
)
break
out.write(chunk)
else:
report.total_bytes += written
report.files.append(
ScanTarget(
path=dest,
filename=safe_name,
declared_ext=os.path.splitext(safe_name)[1].lower(),
logical_path=f"{target.logical_path}!/{name}",
depth=target.depth + 1,
is_extracted=True,
)
)
except Exception as exc: # noqa: BLE001
self._add_finding(
report,
Severity.LOW.value,
"ExtractMemberFailed",
f"成员 {name} 解压失败:{exc}",
0.0,
)
except zipfile.BadZipFile as exc:
self._add_finding(
report,
Severity.MEDIUM.value,
"ArchiveBroken",
f"ZIP 结构损坏:{exc}",
10.0,
)
except Exception as exc: # noqa: BLE001
self._add_finding(
report,
Severity.LOW.value,
"ExtractFailed",
f"解包失败:{exc}",
0.0,
)
# ------------------------------------------------------------------ TAR
def _extract_tar(self, target: ScanTarget, workdir: str, report: ExtractionReport) -> None:
out_dir = self._target_dir(workdir, target)
max_depth = self.settings.max_extract_depth
try:
with tarfile.open(target.path) as tf:
for member in tf.getmembers():
if target.depth + 1 > max_depth:
self._add_finding(
report,
Severity.MEDIUM.value,
"ExtractDepthExceeded",
f"嵌套层级超过上限 {max_depth}",
15.0,
)
report.truncated = True
break
if self._budget_exhausted(report) or self._record_limit(report):
self._add_finding(
report,
Severity.MEDIUM.value,
"ExtractBudgetExceeded",
"解包数量或体积达到上限",
10.0,
)
break
name = member.name
if _is_traversal(name) or member.issym() or member.islnk():
self._add_finding(
report,
Severity.CRITICAL.value,
"PathTraversalBlocked",
f"已拦截路径穿越或链接条目:{name}",
50.0,
)
continue
if not member.isfile():
continue
if member.size > self.settings.max_upload_bytes:
self._add_finding(
report,
Severity.MEDIUM.value,
"MemberTooLarge",
f"成员 {name} 大小超过单文件上限,已跳过",
10.0,
)
continue
safe_name = self._sanitize_name(name)
dest = os.path.join(out_dir, safe_name)
try:
src = tf.extractfile(member)
if src is None:
continue
with src, open(dest, "wb") as out:
written = 0
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > self.settings.max_upload_bytes:
break
out.write(chunk)
report.total_bytes += written
report.files.append(
ScanTarget(
path=dest,
filename=safe_name,
declared_ext=os.path.splitext(safe_name)[1].lower(),
logical_path=f"{target.logical_path}!/{name}",
depth=target.depth + 1,
is_extracted=True,
)
)
except Exception as exc: # noqa: BLE001
self._add_finding(
report,
Severity.LOW.value,
"ExtractMemberFailed",
f"成员 {name} 解压失败:{exc}",
0.0,
)
except tarfile.TarError as exc:
self._add_finding(
report,
Severity.MEDIUM.value,
"ArchiveBroken",
f"TAR 结构异常:{exc}",
10.0,
)
except Exception as exc: # noqa: BLE001
self._add_finding(
report,
Severity.LOW.value,
"ExtractFailed",
f"解包失败:{exc}",
0.0,
)
def _is_traversal(name: str) -> bool:
normalized = name.replace("\\", "/")
if normalized.startswith("/"):
return True
if len(normalized) > 1 and normalized[1] == ":":
return True
return ".." in normalized.split("/")
def cleanup_dir(path: str) -> None:
"""安全删除临时目录。"""
try:
shutil.rmtree(path, ignore_errors=True)
except Exception: # noqa: BLE001
pass
def make_workdir(root: str, prefix: str = "scan_") -> str:
os.makedirs(root, exist_ok=True)
return tempfile.mkdtemp(prefix=prefix, dir=root)

157
app/engines/keywords.py Normal file
View File

@@ -0,0 +1,157 @@
"""内网特征词表引擎(P1)。
数据驱动:词表从 policy.yaml 的 ``keyword_rules`` 段加载,不在代码中硬编码。
支持三类匹配模式:
* ``contains`` — 子串匹配(区分大小写)
* ``regex`` — 正则表达式匹配
* ``any_of`` — 多关键词中任一命中
用途:识别内网特有模板、密级标识、命名规律等——辅助宏例外判断和内容审计方向。
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List
from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
logger = logging.getLogger(__name__)
class KeywordEngine(Engine):
"""内网特征词表匹配引擎。"""
name = "keywords"
layer = "L2B"
version = "1.0"
def __init__(self, keyword_rules: List[Dict[str, Any]] | None = None) -> None:
"""初始化。keyword_rules 来自 PolicyStore.current().keyword_rules。"""
self._rules = keyword_rules or []
# 预编译正则
self._compiled: List[Dict[str, Any]] = []
for rule in self._rules:
pattern = rule.get("pattern", "")
if rule.get("match") == "regex" and pattern:
try:
compiled = re.compile(pattern, re.IGNORECASE if rule.get("ignore_case") else 0)
except re.error:
logger.warning("词表规则正则编译失败: %s", rule.get("name"))
continue
self._compiled.append({**rule, "_re": compiled})
else:
self._compiled.append(rule)
# ------------------------------------------------------------------- 接口实现
def is_enabled(self) -> bool:
return bool(self._rules)
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
result = EngineResult(engine=self.name)
if not self._rules:
return result
findings: List[Finding] = []
try:
text = self._extract_text(target.path, target.declared_ext)
except Exception: # noqa: BLE001
return result
if not text:
return result
for rule in self._compiled:
match_mode = rule.get("match", "contains")
rule_name = rule.get("name", "keyword")
weight = rule.get("weight", 30.0)
severity_str = rule.get("severity", "medium")
severity = getattr(Severity, severity_str.upper(), Severity.MEDIUM).value
hits: List[str] = []
if match_mode == "regex":
compiled = rule.get("_re")
if compiled:
for m in compiled.finditer(text):
snippet = text[max(0, m.start() - 10): m.end() + 10]
hits.append(snippet.strip())
if len(hits) >= 3:
break
elif match_mode == "any_of":
keywords = rule.get("keywords", [])
for kw in keywords:
if kw.lower() in text.lower():
hits.append(kw)
if len(hits) >= 3:
break
elif match_mode == "contains":
pattern = rule.get("pattern", "")
if pattern and (pattern.lower() in text.lower()):
hits.append(pattern)
if hits:
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=severity,
rule=rule_name,
detail=f"词表命中:{rule_name}(模式={match_mode}),命中 {len(hits)} 处:{hits[:2]}",
weight=float(weight),
)
)
result.findings = findings
result.meta["scanned_chars"] = len(text)
return result
# ------------------------------------------------------------------- 内部辅助
@staticmethod
def _extract_text(path: str, ext: str) -> str:
"""从文件中提取可匹配的文本(简化版,够用就行)。
OOXML/PDF/DOCX 等结构化文档优先交给对应的引擎处理,
词表引擎只扫纯文本、代码文件和可直接读的二进制残留字符串。
"""
text_exts = {".txt", ".csv", ".json", ".xml", ".html", ".htm",
".md", ".log", ".ini", ".cfg", ".yaml", ".yml",
".sh", ".bash", ".zsh", ".py", ".pl", ".rb", ".js", ".ts",
".bat", ".cmd", ".ps1", ".vbs", ".sql", ".go", ".java",
".c", ".cpp", ".h", ".cs"}
ext_lower = (ext or "").lower()
if ext_lower in text_exts:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read(256 * 1024)
except OSError:
return ""
# 二进制文件:提取可打印字符串(≥4 字符)
try:
with open(path, "rb") as fh:
data = fh.read(512 * 1024)
except OSError:
return ""
strings: List[str] = []
current = bytearray()
for byte in data:
if 32 <= byte <= 126 or byte in (9,): # 可打印或 tab
current.append(byte)
else:
if len(current) >= 4:
strings.append(current.decode("ascii", errors="replace"))
current = bytearray()
if len(current) >= 4:
strings.append(current.decode("ascii", errors="replace"))
return "\n".join(strings)

1371
app/engines/structure.py Normal file

File diff suppressed because it is too large Load Diff

281
app/i18n_labels.py Normal file
View File

@@ -0,0 +1,281 @@
"""MeshSafe 统一中文标签体系。
所有控制台页面(概览 / 任务历史 / 测试靶场)共用同一套中文映射,
保证同一个后端字段在任何页面显示的名称、颜色、说明完全一致。
设计目标
--------
1. **术语统一** —— verdict / action / severity / engine / mode 全部中文化,
英文原值保留在 title 属性里供运维排查时对照。
2. **风险分级体系** —— 把「放行 / 可疑 / 恶意 / 策略拒绝 / 异常」归入
4 个风险等级(安全 / 注意 / 高危 / 故障),与策略引擎的裁决语义对齐。
3. **单一事实源** —— 新增页面或新增字段时只需在这里登记,
不用再到每个模板里写一遍 if-else。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
# =============================================================================
# 风险等级体系(全局统一)
# =============================================================================
#: 风险等级 → (中文名, 徽章 CSS 类, 语义说明)
RISK_LEVELS: Dict[str, Tuple[str, str, str]] = {
"safe": ("安全", "ok", "未检出任何风险信号,正常放行"),
"notice": ("注意", "warn", "存在可疑信号,建议人工确认后放行"),
"high": ("高危", "bad", "确认存在恶意特征,必须阻断"),
"fault": ("故障", "err", "检测过程异常,按安全优先策略阻断"),
"idle": ("处理中", "muted", "检测尚未完成"),
}
#: 风险等级排序(从高到低),用于排序统计
RISK_ORDER: List[str] = ["high", "notice", "fault", "safe", "idle"]
# =============================================================================
# 裁决结论(verdict)
# =============================================================================
#: verdict → (中文名, 风险等级)
VERDICT_LABELS: Dict[str, Tuple[str, str]] = {
"clean": ("放行", "safe"),
"pass": ("放行", "safe"),
"known": ("放行", "safe"),
"suspicious": ("可疑", "notice"),
"review": ("可疑", "notice"),
"malicious": ("恶意", "high"),
"reject": ("策略拒绝", "fault"),
"error": ("检测异常", "fault"),
"pending": ("处理中", "idle"),
}
# =============================================================================
# 处置动作(action)
# =============================================================================
#: action → (中文名, 风险等级)
ACTION_LABELS: Dict[str, Tuple[str, str]] = {
"pass": ("放行", "safe"),
"pass_with_flag": ("带标记放行", "notice"),
"manual_review": ("人工复核", "notice"),
"reject": ("拦截", "high"),
"reject_and_notify": ("拦截并告警", "high"),
"error": ("异常阻断", "fault"),
"pending": ("处理中", "idle"),
}
# =============================================================================
# 严重度(severity)
# =============================================================================
#: severity → (中文名, 徽章 CSS 类)
SEVERITY_LABELS: Dict[str, Tuple[str, str]] = {
"critical": ("严重", "bad"),
"high": ("高危", "bad"),
"medium": ("中危", "warn"),
"low": ("低危", "notice"),
"info": ("提示", "muted"),
}
# =============================================================================
# 检测引擎(engine)
# =============================================================================
ENGINE_LABELS: Dict[str, str] = {
"structure": "文件结构分析",
"clamav": "ClamAV 病毒库",
"document": "文档深度检测",
"keywords": "关键词匹配",
"yara": "YARA 规则匹配",
"binary": "PE/二进制分析",
"extractor": "归档解压",
}
# =============================================================================
# 扫描模式(mode)
# =============================================================================
MODE_LABELS: Dict[str, str] = {
"sync": "同步",
"async": "异步",
}
# =============================================================================
# 样本分类(测试靶场 category)
# =============================================================================
CATEGORY_LABELS: Dict[str, str] = {
"web_shell": "Webshell",
"office_macro": "Office 宏",
"windows_pe": "Windows 可执行",
"linux_malware": "Linux 恶意脚本",
"zero_tolerance": "零容忍载体",
"encrypted": "加密文档",
"negative": "阴性对照",
"unknown": "未分类",
}
# =============================================================================
# 渠道(channel)—— 摆渡来源
# =============================================================================
CHANNEL_LABELS: Dict[str, str] = {
"default": "默认通道",
"duandao": "摆渡通道",
"console_upload": "控制台上传",
"test_center": "测试靶场",
}
# =============================================================================
# 对外查询接口
# =============================================================================
def verdict_label(verdict: Any) -> str:
"""verdict → 中文名(未知值原样返回)。"""
key = str(verdict or "").strip().lower()
return VERDICT_LABELS.get(key, (str(verdict or "-"), "idle"))[0]
def verdict_risk(verdict: Any) -> str:
"""verdict → 风险等级 key。"""
key = str(verdict or "").strip().lower()
return VERDICT_LABELS.get(key, ("-", "idle"))[1]
def verdict_badge(verdict: Any) -> str:
"""verdict → 徽章 CSS 类。"""
key = str(verdict or "").strip().lower()
return RISK_LEVELS.get(verdict_risk(key), ("-", "muted"))[1]
def action_label(action: Any) -> str:
"""action → 中文名。"""
key = str(action or "").strip().lower()
return ACTION_LABELS.get(key, (str(action or "-"), "idle"))[0]
def action_risk(action: Any) -> str:
"""action → 风险等级 key。"""
key = str(action or "").strip().lower()
return ACTION_LABELS.get(key, ("-", "idle"))[1]
def action_badge(action: Any) -> str:
"""action → 徽章 CSS 类。"""
key = str(action or "").strip().lower()
return RISK_LEVELS.get(action_risk(key), ("-", "muted"))[1]
def severity_label(severity: Any) -> str:
"""severity → 中文名。"""
key = str(severity or "").strip().lower()
return SEVERITY_LABELS.get(key, (str(severity or "-"), "muted"))[0]
def severity_badge(severity: Any) -> str:
"""severity → 徽章 CSS 类。"""
key = str(severity or "").strip().lower()
return SEVERITY_LABELS.get(key, ("-", "muted"))[1]
def engine_label(engine: Any) -> str:
"""engine → 中文名。"""
key = str(engine or "").strip().lower()
return ENGINE_LABELS.get(key, str(engine or "-"))
def mode_label(mode: Any) -> str:
"""mode → 中文名。"""
key = str(mode or "").strip().lower()
return MODE_LABELS.get(key, str(mode or "-"))
def category_label(category: Any) -> str:
"""category → 中文名。"""
key = str(category or "").strip().lower()
return CATEGORY_LABELS.get(key, str(category or "-"))
def channel_label(channel: Any) -> str:
"""channel → 中文名。"""
key = str(channel or "").strip().lower()
return CHANNEL_LABELS.get(key, str(channel or "-"))
def risk_label(risk: Any) -> str:
"""风险等级 key → 中文名。"""
key = str(risk or "").strip().lower()
return RISK_LEVELS.get(key, (str(risk or "-"), "muted", ""))[0]
def risk_badge(risk: Any) -> str:
"""风险等级 key → 徽章 CSS 类。"""
key = str(risk or "").strip().lower()
return RISK_LEVELS.get(key, ("-", "muted", ""))[1]
def risk_desc(risk: Any) -> str:
"""风险等级 key → 语义说明。"""
key = str(risk or "").strip().lower()
return RISK_LEVELS.get(key, ("-", "muted", ""))[2]
# =============================================================================
# 组合辅助
# =============================================================================
def risk_matrix() -> List[Dict[str, Any]]:
"""返回完整风险等级体系(供页面渲染说明表/图例)。"""
return [
{
"key": key,
"label": RISK_LEVELS[key][0],
"badge": RISK_LEVELS[key][1],
"desc": RISK_LEVELS[key][2],
}
for key in RISK_ORDER
if key in RISK_LEVELS
]
def verdict_summary(verdict_dist: Dict[str, int]) -> List[Dict[str, Any]]:
"""把 {verdict: count} 转成按风险等级聚合的展示列表。
输出按「高危 → 注意 → 故障 → 安全 → 处理中」排序,
每项含:中文名、徽章类、计数、占比、原始 verdict key。
同风险等级内的多个 verdict 会被合并(例如 malicious 与 reject 都是高危)。
"""
total = sum(verdict_dist.values()) or 0
# 先按 verdict 映射到风险等级
buckets: Dict[str, Dict[str, Any]] = {}
for v, cnt in verdict_dist.items():
risk = verdict_risk(v)
if risk not in buckets:
buckets[risk] = {
"key": risk,
"label": risk_label(risk),
"badge": risk_badge(risk),
"desc": risk_desc(risk),
"count": 0,
"verdicts": [],
}
buckets[risk]["count"] += int(cnt)
buckets[risk]["verdicts"].append(str(v))
items = list(buckets.values())
for it in items:
it["pct"] = round(it["count"] / total * 100, 1) if total else 0.0
items.sort(key=lambda x: RISK_ORDER.index(x["key"]) if x["key"] in RISK_ORDER else 99)
return items

87
app/logging_config.py Normal file
View File

@@ -0,0 +1,87 @@
"""统一日志配置。
特性:
- 五级日志:DEBUG / INFO / WARNING / ERROR / CRITICAL,默认 INFO
- 同时输出到控制台和文件(RotatingFileHandler 按大小轮转)
- 通过环境变量 ``MESHSAFE_LOG_LEVEL`` 控制级别
- 通过环境变量 ``MESHSAFE_LOG_DIR`` 控制日志文件目录
- 日志文件按 10 MB 轮转,保留最近 5 份
- 自动为 FastAPI / uvicorn / sqlalchemy 等第三方库设置一致的级别
"""
from __future__ import annotations
import logging
import logging.handlers
import os
import sys
from pathlib import Path
from app.config import get_settings
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
_BACKUP_COUNT = 5
def setup_logging() -> None:
"""初始化全局日志配置。幂等:多次调用不会重复添加 handler。"""
settings = get_settings()
level_name = os.environ.get("MESHSAFE_LOG_LEVEL", settings.log_level).upper()
level = getattr(logging, level_name, logging.INFO)
log_dir = os.environ.get("MESHSAFE_LOG_DIR", "/var/log/meshsafe")
Path(log_dir).mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger()
root_logger.setLevel(level)
# 幂等:已初始化则跳过
if root_logger.handlers:
# 更新级别即可
root_logger.setLevel(level)
for handler in root_logger.handlers:
handler.setLevel(level)
return
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT)
# 控制台 handler
console = logging.StreamHandler(sys.stdout)
console.setLevel(level)
console.setFormatter(formatter)
root_logger.addHandler(console)
# 文件 handler(按大小轮转)
log_file = os.path.join(log_dir, "meshsafe.log")
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# 第三方库级别收敛:避免 uvicorn.access / sqlalchemy 刷屏
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("redis").setLevel(logging.WARNING)
logging.getLogger("pydantic").setLevel(logging.WARNING)
# 启动日志
root_logger.info(
"日志系统初始化完成:级别=%s,日志文件=%s(按 %d MB 轮转,保留 %d 份)",
logging.getLevelName(level),
log_file,
_MAX_BYTES // (1024 * 1024),
_BACKUP_COUNT,
)
def get_logger(name: str) -> logging.Logger:
"""获取模块级 logger。"""
return logging.getLogger(name)

568
app/main.py Normal file
View File

@@ -0,0 +1,568 @@
"""MeshSafe 服务入口(v1.1 — 接口对齐对外文档)。
启动方式::
uvicorn app.main:app --host 0.0.0.0 --port 8443
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import queue
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, UploadFile, status
from app.config import get_settings
from app.engines.structure import StructureEngine
from app.logging_config import setup_logging
from app.security.deps import (
authenticate,
ResponseSigningMiddleware,
ClientContext,
)
from app.security.ratelimit import rate_limit
from app.services.policy import PolicyEngine, PolicyStore
from app.services.scanner import Scanner, ScanOutcome
# ---------------------------------------------------------------------------
# 启动时完成配置加载 + 日志初始化
# ---------------------------------------------------------------------------
setup_logging()
logger = logging.getLogger(__name__)
settings = get_settings()
# ---------------------------------------------------------------------------
# 策略存储 + 扫描器(应用级单例)
# ---------------------------------------------------------------------------
_policy_file = os.environ.get("MESHSAFE_POLICY_FILE", "config/policy.yaml")
policy_store = PolicyStore(policy_file=_policy_file)
scanner = Scanner(policy_store)
# ---------------------------------------------------------------------------
# 异步扫描队列(内存实现,生产可替换为 Redis)
# ---------------------------------------------------------------------------
_task_store: Dict[str, Dict[str, Any]] = {}
_task_lock = threading.Lock()
_task_queue: "queue.Queue[tuple]" = queue.Queue(maxsize=256)
_WORKER_COUNT = int(os.environ.get("MESHSAFE_WORKERS", "2"))
def _worker_loop() -> None:
"""后台 worker:从队列取任务执行扫描,结果存入 _task_store。"""
local_logger = logging.getLogger("app.worker")
while True:
try:
task_id, file_path, original_filename, channel, client_id = _task_queue.get()
except Exception:
continue
with _task_lock:
_task_store[task_id]["status"] = "running"
try:
outcome = scanner.scan_file(
file_path=file_path,
original_filename=original_filename,
task_id=task_id,
channel=channel,
client_id=client_id,
mode="async",
)
try:
os.unlink(file_path)
except OSError:
pass
with _task_lock:
_task_store[task_id].update({
"status": "completed",
"result": outcome.to_response(include_details=True),
"finished_at": time.time(),
})
local_logger.info("异步扫描完成 task_id=%s verdict=%s action=%s",
task_id, outcome.verdict, outcome.action)
except Exception as exc: # noqa: BLE001
local_logger.exception("异步扫描失败 task_id=%s", task_id)
with _task_lock:
_task_store[task_id].update({
"status": "failed",
"error": str(exc),
"finished_at": time.time(),
})
def _start_workers() -> None:
for i in range(_WORKER_COUNT):
t = threading.Thread(target=_worker_loop, name=f"scan-worker-{i}", daemon=True)
t.start()
logger.info("启动 %d 个异步扫描 worker 线程", _WORKER_COUNT)
def _file_size_limit() -> int:
"""当前策略允许的最大文件大小(字节)。"""
g = policy_store.current().raw.get("global", {})
return int(g.get("max_file_size", 1610612736))
def _sync_max_size() -> int:
"""超过这个大小自动走异步(字节)。"""
g = policy_store.current().raw.get("global", {})
return int(g.get("sync_max_size", 536870912))
app = FastAPI(
title="MeshSafe 文件安全检测服务",
version="1.1.0",
description=(
"为光闸摆渡场景设计的独立文件安全检测服务。"
'对外只做一件事:判断"这个文件能不能传进内网",'
"把检测结论交还给摆渡系统决定。"
),
)
# =====================================================================
# 全局异常处理 —— /console/* 页面 401 友好跳转登录页
# =====================================================================
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(HTTPException)
async def _http_exception_handler(request: Request, exc: HTTPException):
"""统一处理 HTTPException。
对于 /console/* 路由:
- 401 → 302 跳登录页(浏览器)或 JSON(API)
- 403 → 提示 admin 权限不够
对于 /api/* 路由:
- 保持 JSON 原样返回(API 调用方需要结构化错误)
"""
path = request.url.path
# 控制台路由的浏览器请求 —— 401/403 友好化
if path.startswith("/console/") and path != "/console/login":
accept = request.headers.get("accept", "")
want_html = "text/html" in accept or "*/*" in accept
if exc.status_code == 401 and want_html:
return RedirectResponse(
url="/console/login?reason=session_expired",
status_code=302,
)
if exc.status_code == 403 and want_html:
return JSONResponse(
status_code=403,
content={"detail": "需要 admin 权限才能执行此操作"},
)
# 其他情况保持默认 JSON 返回
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# 响应签名中间件(为所有 /api/* 响应添加 HMAC 签名)
app.add_middleware(ResponseSigningMiddleware)
# 控制台路由(P2,无签名要求)
try:
from app.console import router as console_router, diag_api_router
app.include_router(console_router)
app.include_router(diag_api_router)
except Exception: # noqa: BLE001
logger.warning("控制台路由加载失败(Jinja2 未安装或 DB 不可用)", exc_info=True)
# 静态资源(logo 等)
from fastapi.staticfiles import StaticFiles as _StaticFiles
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_static_dir = os.path.join(_project_root, "static")
os.makedirs(_static_dir, exist_ok=True)
app.mount("/static", _StaticFiles(directory=_static_dir), name="static")
@app.on_event("startup")
async def _startup() -> None:
logger.info("MeshSafe 启动中:env=%s port=%d", settings.env, settings.port)
try:
policy = policy_store.current()
logger.info(
"策略加载成功 version=%s zero_tolerance_rules=%d macro_exceptions=%d",
policy.version,
len(policy.zero_tolerance_rules),
len(policy.macro_exceptions or []),
)
except Exception: # noqa: BLE001
logger.exception("策略加载失败,服务将处于 fail_closed 状态")
_start_workers()
# ---------- 诊断套件启动 ----------
try:
from app.diagnostic.config import get_diag_config
from app.diagnostic.docker_probe import detect_docker_access
from app.diagnostic.scheduler import start_timers
diag_cfg = get_diag_config()
if diag_cfg.enabled:
docker_info = detect_docker_access(mode=diag_cfg.docker_mode)
start_timers(docker_info["status"], docker_info)
logger.info(
"MeshSafe Diagnostics 已启用: docker=%s status=%s",
diag_cfg.docker_mode, docker_info["status"],
)
else:
logger.info("MeshSafe Diagnostics: disabled(config.enabled=false)")
except Exception: # noqa: BLE001
logger.exception("诊断套件启动失败(不影响主服务)")
# ===========================================================================
# API 路由(对外文档 v1.1)
# ===========================================================================
# ---------------------------------------------------------------------------
# 4.5 健康检查
# ---------------------------------------------------------------------------
@app.get("/api/v1/health", tags=["health"])
async def health() -> Dict[str, Any]:
"""健康检查与引擎状态(公开接口,无需签名)。"""
engines = scanner.health()
engine_map: Dict[str, Any] = {}
for name, info in engines.items():
entry = {"available": info.get("ok", info.get("available", False))}
if "version" in info:
entry["version"] = info["version"]
if "signature_date" in info:
entry["signature_updated_at"] = info["signature_date"]
if "rules_loaded" in info:
entry["rules_loaded"] = info["rules_loaded"]
if "rules_dir" in info:
entry["rules_dir"] = info["rules_dir"]
engine_map[name] = entry
with _task_lock:
pending_count = sum(1 for t in _task_store.values() if t["status"] in ("pending", "running"))
return {
"status": "healthy",
"policy_version": policy_store.current().version,
"engines": engine_map,
"queue": {
"pending": pending_count,
"running": pending_count,
},
"server_time": datetime.now(timezone.utc).isoformat(),
}
# ---------------------------------------------------------------------------
# 4.1 提交扫描(同步/异步自动切换)
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans", tags=["scan"])
async def scans_submit(
file: UploadFile = File(...),
channel: str = Form("default"),
priority: str = Form("normal"),
ctx: ClientContext = Depends(authenticate),
_ = Depends(rate_limit(max_requests_per_min=60, label="scans")),
) -> Dict[str, Any]:
"""提交文件进行安全检测。
请求体相同,服务根据文件大小自动选择同步或异步:
- ≤ sync_max_size(默认 512MB)→ 同步:请求内返回完整结论
- > sync_max_size → 异步:返回 task_id,轮询 GET /api/v1/scans/{task_id}
"""
client_id = ctx.client_id
task_id = uuid.uuid4().hex
# 1) 保存到临时文件
suffix = os.path.splitext(file.filename or "")[1] or ""
try:
content = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
except Exception as exc: # noqa: BLE001
logger.exception("接收上传文件失败 task_id=%s", task_id)
raise HTTPException(status_code=400, detail=f"文件接收失败: {exc}")
file_size = len(content)
del content # 尽快释放内存
# 2) 文件大小硬上限(超过直接 413)
if file_size > _file_size_limit():
try:
os.unlink(tmp_path)
except OSError:
pass
raise HTTPException(
status_code=413,
detail=f"文件大小 {file_size/1024/1024:.0f}MB 超过上限 {_file_size_limit()/1024/1024:.0f}MB",
)
# 3) 自动选择同步 / 异步
if file_size <= _sync_max_size():
# ---- 同步扫描 ----
import asyncio as _asyncio
try:
loop = _asyncio.get_running_loop()
outcome = await loop.run_in_executor(
None,
lambda: scanner.scan_file(
file_path=tmp_path,
original_filename=file.filename or f"upload{suffix}",
task_id=task_id,
channel=channel,
client_id=client_id,
),
)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# 同步结果也存入 _task_store,支持后续 GET /api/v1/scans/{task_id} 查询和 review
with _task_lock:
_task_store[task_id] = {
"status": "completed",
"result": outcome.to_response(include_details=True),
"finished_at": time.time(),
"filename": file.filename,
"channel": channel,
}
return outcome.to_response(include_details=True)
else:
# ---- 异步扫描 ----
with _task_lock:
if len(_task_store) >= 1024:
finished = [tid for tid, t in _task_store.items()
if t["status"] in ("completed", "failed")]
for tid in finished[-512:]:
del _task_store[tid]
_task_store[task_id] = {
"status": "pending",
"submitted_at": time.time(),
"filename": file.filename,
"channel": channel,
}
try:
_task_queue.put_nowait((task_id, tmp_path, file.filename or f"upload{suffix}",
channel, client_id))
except queue.Full:
with _task_lock:
_task_store[task_id] = {"status": "failed", "error": "扫描队列已满"}
try:
os.unlink(tmp_path)
except OSError:
pass
raise HTTPException(status_code=503, detail="扫描队列已满,请稍后重试")
return {
"task_id": task_id,
"status": "pending",
"message": "任务已受理,请轮询 /api/v1/scans/{task_id} 获取结果",
}
# ---------------------------------------------------------------------------
# 4.2 查询扫描结果
# ---------------------------------------------------------------------------
@app.get("/api/v1/scans/{task_id}", tags=["scan"])
async def scans_get(task_id: str, ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""查询扫描结果。同步模式下用于重试补查,异步模式下用于轮询。"""
with _task_lock:
task = _task_store.get(task_id)
if not task:
raise HTTPException(status_code=404, detail=f"任务 {task_id} 不存在或已过期")
status_val = task["status"]
if status_val == "completed":
return task["result"]
if status_val == "failed":
return {
"task_id": task_id,
"status": "failed",
"verdict": "error",
"action": "error",
"score": 0.0,
"reason": task.get("error", "未知错误"),
"error": task.get("error"),
"finished_at": datetime.fromtimestamp(task.get("finished_at", time.time()), tz=timezone.utc).isoformat(),
}
# pending / running → 返回占位结构
return {
"task_id": task_id,
"status": status_val,
"verdict": "pending",
"action": "pending",
"score": 0.0,
"reason": "",
}
# ---------------------------------------------------------------------------
# 4.3 重新扫描
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans/{task_id}/rescan", tags=["scan"])
async def scans_rescan(
task_id: str,
file: UploadFile = File(...),
ctx: ClientContext = Depends(authenticate),
) -> Dict[str, Any]:
"""使用当前生效策略重新扫描。
原始文件可能已被清理(进程退出即 unlink),因此需要重新上传文件。
"""
with _task_lock:
old_task = _task_store.get(task_id)
suffix = os.path.splitext(file.filename or "")[1] or ""
try:
content = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
except Exception as exc: # noqa: BLE001
logger.exception("rescan 接收失败 task_id=%s", task_id)
raise HTTPException(status_code=400, detail=f"文件接收失败: {exc}")
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=file.filename or (old_task.get("filename", f"upload{suffix}") if old_task else f"upload{suffix}"),
task_id=task_id,
channel=old_task.get("channel", "default") if old_task else "default",
client_id=ctx.client_id,
),
)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return outcome.to_response(include_details=True)
# ---------------------------------------------------------------------------
# 4.4 人工复核回写
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans/{task_id}/review", tags=["scan"])
async def scans_review(
task_id: str,
body: Dict[str, Any] = Body(...),
ctx: ClientContext = Depends(authenticate),
) -> Dict[str, Any]:
"""回写人工复核结论。
请求体::
{ "decision": "pass" | "reject", "comment": "..." }
"""
decision = body.get("decision", "").lower()
comment = body.get("comment", "")
if decision not in ("pass", "reject"):
raise HTTPException(status_code=400, detail="decision 必须是 'pass' 或 'reject'")
if not comment.strip():
raise HTTPException(status_code=400, detail="comment 必填(审计要求)")
with _task_lock:
task = _task_store.get(task_id)
if task:
task["review_decision"] = decision
task["review_comment"] = comment
task["reviewed_by"] = ctx.client_id
task["reviewed_at"] = time.time()
return {
"task_id": task_id,
"decision": decision,
"comment": comment,
"recorded_at": datetime.now(timezone.utc).isoformat(),
"reviewed_by": ctx.client_id,
}
# ---------------------------------------------------------------------------
# 4.6 查询当前策略
# ---------------------------------------------------------------------------
@app.get("/api/v1/policies", tags=["policy"])
async def policies_get(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""查询当前生效的全局策略(只读)。"""
policy = policy_store.current()
return {
"version": policy.version,
"sha256": policy.sha256,
"content": policy.raw,
}
# ---------------------------------------------------------------------------
# 4.7 策略热重载
# ---------------------------------------------------------------------------
@app.post("/api/v1/policies/reload", tags=["policy"])
async def policies_reload(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""从策略文件重新加载(无需重启)。"""
try:
policy = policy_store.reload()
logger.info("策略热重载成功 version=%s", policy.version)
return {
"reloaded": True,
"version": policy.version,
"sha256": policy.sha256,
}
except Exception as exc: # noqa: BLE001
logger.exception("策略热重载失败")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"策略加载失败: {exc}",
)
# ---------------------------------------------------------------------------
# 4.8 审计日志完整性校验
# ---------------------------------------------------------------------------
@app.get("/api/v1/audit/verify", tags=["audit"])
async def audit_verify(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""校验审计日志哈希链完整性。
当前实现为 stub(哈希链待二期接入)—— 返回 intact=True 表示审计链路完好。
"""
return {
"checked": 0,
"intact": True,
"broken_at_id": None,
"verified_at": datetime.now(timezone.utc).isoformat(),
"note": "审计哈希链为二期功能,当前返回 intact=True 占位",
}
# ---------------------------------------------------------------------------
# 直接运行支持
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=False)

176
app/models.py Normal file
View File

@@ -0,0 +1,176 @@
"""ORM 模型定义。
涉及表:
- ``scan_tasks`` 扫描任务(同步与异步任务共用一张表)
- ``scan_findings`` 单条检测信号
- ``audit_logs`` 审计日志(带哈希链,防篡改)
- ``console_users`` 控制台账号
- ``login_attempts`` 登录失败记录(用于锁定)
- ``policy_versions`` 策略版本快照
- ``review_records`` 人工复核记录
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def new_id() -> str:
return uuid.uuid4().hex
class ScanTask(Base):
__tablename__ = "scan_tasks"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=new_id)
channel: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_id: Mapped[str] = mapped_column(String(64), index=True)
filename: Mapped[str] = mapped_column(String(512))
declared_ext: Mapped[str] = mapped_column(String(32), default="")
size: Mapped[int] = mapped_column(Integer, default=0)
sha256: Mapped[str] = mapped_column(String(64), index=True)
md5: Mapped[str] = mapped_column(String(32), default="")
real_type: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
mode: Mapped[str] = mapped_column(String(16), default="sync")
verdict: Mapped[str] = mapped_column(String(24), default="pending")
action: Mapped[str] = mapped_column(String(32), default="pending")
score: Mapped[float] = mapped_column(Float, default=0.0)
reason: Mapped[str] = mapped_column(Text, default="")
policy_version: Mapped[str] = mapped_column(String(64), default="")
detail: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
findings: Mapped[list["ScanFinding"]] = relationship(
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
)
class ScanFinding(Base):
__tablename__ = "scan_findings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(
String(64), ForeignKey("scan_tasks.id", ondelete="CASCADE"), index=True
)
engine: Mapped[str] = mapped_column(String(64))
layer: Mapped[str] = mapped_column(String(8))
severity: Mapped[str] = mapped_column(String(16))
rule: Mapped[str] = mapped_column(String(256))
detail: Mapped[str] = mapped_column(Text, default="")
extracted_path: Mapped[str] = mapped_column(String(1024), default="")
weight: Mapped[float] = mapped_column(Float, default=0.0)
task: Mapped[ScanTask] = relationship(back_populates="findings")
class AuditLog(Base):
"""审计日志。
每条记录包含 ``prev_hash`` 与 ``row_hash``,形成哈希链。
任一条记录被篡改或删除,后续记录的链式校验都会失败,可被检测出来。
"""
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
actor: Mapped[str] = mapped_column(String(128), default="system")
actor_role: Mapped[str] = mapped_column(String(32), default="system")
action: Mapped[str] = mapped_column(String(64), index=True)
target: Mapped[str] = mapped_column(String(256), default="")
channel: Mapped[str] = mapped_column(String(64), default="")
client_ip: Mapped[str] = mapped_column(String(64), default="")
outcome: Mapped[str] = mapped_column(String(24), default="success")
detail: Mapped[dict] = mapped_column(JSON, default=dict)
prev_hash: Mapped[str] = mapped_column(String(64), default="")
row_hash: Mapped[str] = mapped_column(String(64), default="")
__table_args__ = (Index("ix_audit_logs_action_ts", "action", "ts"),)
class ConsoleUser(Base):
__tablename__ = "console_users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
role: Mapped[str] = mapped_column(String(32), default="admin")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), index=True)
client_ip: Mapped[str] = mapped_column(String(64), default="")
success: Mapped[bool] = mapped_column(Boolean, default=False)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
class PolicyVersion(Base):
__tablename__ = "policy_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version: Mapped[str] = mapped_column(String(64), unique=True, index=True)
content: Mapped[dict] = mapped_column(JSON, default=dict)
sha256: Mapped[str] = mapped_column(String(64), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class ReviewRecord(Base):
__tablename__ = "review_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(String(64), ForeignKey("scan_tasks.id", ondelete="CASCADE"), index=True)
reviewer: Mapped[str] = mapped_column(String(64))
decision: Mapped[str] = mapped_column(String(32))
comment: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class IdempotencyRecord(Base):
"""幂等记录:同一文件哈希 + 同一策略版本只扫描一次。"""
__tablename__ = "idempotency_records"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sha256: Mapped[str] = mapped_column(String(64), index=True)
policy_version: Mapped[str] = mapped_column(String(64), index=True)
task_id: Mapped[str] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
__table_args__ = (UniqueConstraint("sha256", "policy_version", name="uq_idem_hash_policy"),)

1
app/security/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""安全包。"""

106
app/security/crypto.py Normal file
View File

@@ -0,0 +1,106 @@
"""密码学基础工具:密钥派生、口令哈希、常数时间比较。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import os
from typing import Tuple
# 口令哈希迭代次数。等保三级要求存储口令不可逆,PBKDF2-HMAC-SHA256
# 迭代 60 万次在现代 CPU 上约 0.3~0.6 秒,兼顾抗暴力破解与可用性。
PBKDF2_ITERATIONS = 600_000
PBKDF2_ALGORITHM = "sha256"
SALT_BYTES = 16
def derive_key(secret: str, salt: str, info: str, length: int = 32) -> bytes:
"""使用 HKDF-SHA256 从调用方密钥派生用途隔离的子密钥。
不同用途使用不同 ``info`` 标签,使得请求签名密钥与响应签名密钥相互独立。
"""
prk = hmac.new(
salt.encode("utf-8"), secret.encode("utf-8"), hashlib.sha256
).digest()
okm = b""
block = b""
counter = 1
while len(okm) < length:
block = hmac.new(prk, block + info.encode("utf-8") + bytes([counter]), hashlib.sha256).digest()
okm += block
counter += 1
return okm[:length]
def derive_client_signing_key(secret: str, client_id: str) -> bytes:
return derive_key(secret, client_id, "meshsafe/api/v1/request-signing")
def derive_client_response_key(secret: str, client_id: str) -> bytes:
return derive_key(secret, client_id, "meshsafe/api/v1/response-signing")
def hash_password(password: str) -> str:
"""生成 ``pbkdf2_sha256$迭代次数$盐$哈希`` 格式的口令哈希。"""
salt = os.urandom(SALT_BYTES)
dk = hashlib.pbkdf2_hmac(
PBKDF2_ALGORITHM, password.encode("utf-8"), salt, PBKDF2_ITERATIONS
)
return "$".join(
[
"pbkdf2_sha256",
str(PBKDF2_ITERATIONS),
base64.b64encode(salt).decode("ascii"),
base64.b64encode(dk).decode("ascii"),
]
)
def verify_password(password: str, stored: str) -> bool:
"""校验口令。格式非法时返回 False,不抛出异常。"""
try:
algo, iterations, salt_b64, hash_b64 = stored.split("$")
if algo != "pbkdf2_sha256":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(hash_b64)
actual = hashlib.pbkdf2_hmac(
PBKDF2_ALGORITHM, password.encode("utf-8"), salt, int(iterations)
)
return hmac.compare_digest(actual, expected)
except Exception: # noqa: BLE001
return False
def needs_rehash(stored: str) -> bool:
"""判断存量口令哈希是否需要按当前参数重新计算。"""
try:
algo, iterations, _, _ = stored.split("$")
return algo != "pbkdf2_sha256" or int(iterations) < PBKDF2_ITERATIONS
except Exception: # noqa: BLE001
return True
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_file(path: str, chunk_size: int = 1024 * 1024) -> Tuple[str, int]:
"""流式计算文件 SHA256 与 MD5,返回 (sha256, md5, size)。"""
sha = hashlib.sha256()
md5 = hashlib.md5()
size = 0
with open(path, "rb") as fh:
while True:
chunk = fh.read(chunk_size)
if not chunk:
break
sha.update(chunk)
md5.update(chunk)
size += len(chunk)
return sha.hexdigest(), md5.hexdigest(), size
def constant_time_equals(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))

261
app/security/deps.py Normal file
View File

@@ -0,0 +1,261 @@
"""API 安全依赖:认证、签名校验、防重放、限流、响应签名。"""
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING, Any, Dict, Optional
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import get_settings
from app.security import signing
from app.security.crypto import constant_time_equals
_settings = get_settings()
# redis 是可选依赖——没装时限流和防重放降级为内存策略(_check_rate_limit / nonce 检查里已兜底)
try:
import redis as _redis # type: ignore
except ModuleNotFoundError:
_redis = None
if TYPE_CHECKING:
import redis
_redis_client: Optional["redis.Redis"] = None
# 响应体原始字节暂存键,供响应签名中间件复用
RAW_BODY_ATTR = "_meshsafe_raw_body"
CLIENT_ATTR = "_meshsafe_client"
def get_redis():
"""懒加载 Redis 客户端。未安装 redis 包或连接失败时由调用方兜底。"""
global _redis_client
if _redis is None:
raise RuntimeError("redis 包未安装")
if _redis_client is None:
_redis_client = _redis.Redis.from_url(
_settings.redis_url, decode_responses=True, socket_timeout=5
)
return _redis_client
class ClientContext:
"""已通过认证的调用方上下文。"""
def __init__(self, client_id: str, secret: str, role: str = "operator") -> None:
self.client_id = client_id
self.secret = secret
self.role = role
def _lookup_client(client_id: str) -> Optional[ClientContext]:
for item in _settings.parsed_api_clients:
if constant_time_equals(item["client_id"], client_id):
return ClientContext(item["client_id"], item["secret"])
return None
def _check_rate_limit(client_id: str, path: str) -> None:
"""基于 Redis 的滑动窗口限流,防止接口被暴力试探。"""
try:
r = get_redis()
bucket = int(time.time() // 60)
key = f"ms:rl:{client_id}:{path}:{bucket}"
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 120)
count = pipe.execute()[0]
limit = 120 if "/scans" in path else 600
if count > limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="请求过于频繁,已触发限流",
)
except HTTPException:
raise
except Exception: # noqa: BLE001 Redis 不可用时不阻断主流程
return
async def authenticate(request: Request) -> ClientContext:
"""认证与签名校验依赖。
依次执行:
1. 解析并校验调用方标识
2. 时间戳窗口校验(防重放)
3. nonce 一次性校验
4. 请求体哈希一致性校验
5. HMAC 签名校验
"""
client_id = request.headers.get("X-MS-Client-Id", "")
timestamp = request.headers.get("X-MS-Timestamp", "")
nonce = request.headers.get("X-MS-Nonce", "")
body_hash = request.headers.get("X-MS-Body-SHA256", "")
signature = request.headers.get("X-MS-Signature", "")
if not client_id or not timestamp or not nonce or not signature:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="缺少必要的认证请求头",
)
ctx = _lookup_client(client_id)
if ctx is None:
# 不区分"不存在"与"密钥错误",避免枚举 client_id
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
# 1) 时间戳窗口
try:
ts_int = int(timestamp)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
skew = abs(int(time.time()) - ts_int)
if skew > _settings.signature_window:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"请求时间戳超出允许偏差({_settings.signature_window} 秒)",
)
# 2) nonce 一次性
try:
r = get_redis()
nonce_key = f"ms:nonce:{client_id}:{nonce}"
if not r.set(nonce_key, "1", nx=True, ex=_settings.signature_window * 2):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="重复请求,已拒绝"
)
except HTTPException:
raise
except Exception: # noqa: BLE001
pass
# 3) 读取请求体并校验哈希
# 注意:multipart/form-data 请求体已被 FastAPI UploadFile 消费,不能再 await request.body()
# 此时客户端已在 X-MS-Body-SHA256 里提供哈希,直接用即可
body: bytes = b""
ct = request.headers.get("content-type", "").lower()
if not ct.startswith("multipart/"):
try:
body = await request.body()
except RuntimeError:
# stream 已被消费(比如中间件或 UploadFile),用空 body 兜底
body = b""
skip_hash = ct.startswith("multipart/")
if not signing.verify_request_signature(
ctx.secret,
ctx.client_id,
request.method,
request.url.path,
dict(request.query_params),
body,
timestamp,
nonce,
body_hash,
signature,
skip_body_hash_check=skip_hash,
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证失败")
_check_rate_limit(ctx.client_id, request.url.path)
request.state.client = ctx
return ctx
async def require_admin(ctx: ClientContext = Depends(authenticate)) -> ClientContext:
"""要求调用方具备完整 findings 权限。"""
if ctx.role != "operator":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="当前凭证无权查看完整检测明细",
)
return ctx
class ResponseSigningMiddleware(BaseHTTPMiddleware):
"""为所有 API 响应添加签名头。
响应体在读取过程中暂存原始字节,签名基于原始字节计算,
避免因 JSON 序列化差异导致客户端验签失败。
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if not _settings.sign_response:
return response
if request.url.path.startswith("/api/"):
ctx = getattr(request.state, "client", None)
if ctx is None:
return response
body = getattr(response, RAW_BODY_ATTR, None)
if body is None:
body = b""
if hasattr(response, "body_iterator"):
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk if isinstance(chunk, bytes) else chunk.encode("utf-8"))
body = b"".join(chunks)
response = Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
# 删除旧 content-length(重建后会自动重新计算)
try:
del response.headers["content-length"]
except KeyError:
pass
headers = signing.sign_response(ctx.secret, ctx.client_id, body)
for key, value in headers.items():
response.headers[key] = value
return response
def build_signed_json(ctx: ClientContext, payload: Dict[str, Any], status_code: int = 200) -> JSONResponse:
"""构造带签名的 JSON 响应(供直接返回场景使用)。"""
body = signing.canonical_json_bytes(payload)
response = JSONResponse(content=payload, status_code=status_code)
if _settings.sign_response:
for key, value in signing.sign_response(ctx.secret, ctx.client_id, body).items():
response.headers[key] = value
return response
def redact_for_role(payload: Dict[str, Any], role: str) -> Dict[str, Any]:
"""按角色裁剪响应内容,降低信息泄露面。
``basic`` 角色只返回结论摘要,不返回引擎明细与提取文件路径,
避免通过差异响应反推检测逻辑。
"""
if role == "operator":
return payload
allowed_top = {
"task_id",
"verdict",
"score",
"action",
"reason",
"status",
"file",
"meta",
}
trimmed = {k: v for k, v in payload.items() if k in allowed_top}
file_info = trimmed.get("file")
if isinstance(file_info, dict):
trimmed["file"] = {
"sha256": file_info.get("sha256"),
"size": file_info.get("size"),
"real_type": file_info.get("real_type"),
}
trimmed["detail_truncated"] = True
return trimmed

113
app/security/ratelimit.py Normal file
View File

@@ -0,0 +1,113 @@
"""接口限流(Token bucket 滑窗)。
设计:
- 客户端维度(按 client_id 或 IP),每分钟请求配额
- 优先用 Redis(多 worker 共享计数),Redis 不可用时降级为内存字典
- 返回 429 + Retry-After header
"""
from __future__ import annotations
import threading
import time
from typing import Optional
from fastapi import Depends, HTTPException, Request, status
# ---------- 内存降级模式 ----------
# key -> (window_start_timestamp, count)
_MEM_BUCKET: dict[str, tuple[float, int]] = {}
_MEM_LOCK = threading.Lock()
_MEM_WINDOW = 60 # 秒
def _get_redis():
"""懒加载 Redis(避免模块 import 时没装直接崩)。"""
try:
import redis # type: ignore
except Exception: # noqa: BLE001
return None
from app.config import get_settings
try:
return redis.from_url(get_settings().redis_url, decode_responses=True)
except Exception: # noqa: BLE001
return None
def _memory_check(key: str, limit: int) -> bool:
"""内存滑窗:返回 True 表示允许,False 表示超限。"""
now = time.time()
with _MEM_LOCK:
start_ts, count = _MEM_BUCKET.get(key, (now, 0.0))
if now - start_ts > _MEM_WINDOW:
# 窗口外,重建
_MEM_BUCKET[key] = (now, 1)
return True
if count >= limit:
return False
_MEM_BUCKET[key] = (start_ts, count + 1)
return True
def _redis_check(client_key: str, limit: int) -> bool:
"""Redis 滑窗(INCR + EXPIRE)。"""
r = _get_redis()
if r is None:
return _memory_check(client_key, limit)
try:
key = f"meshsafe:rl:{client_key}"
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, _MEM_WINDOW)
results = pipe.execute()
count = results[0]
return count <= limit
except Exception: # noqa: BLE001
return _memory_check(client_key, limit)
class RateLimiter:
"""限流依赖:返回一个 callable 给 Depends。
用法:
@app.post("/scan/sync")
async def scan_sync(..., limiter=Depends(RateLimiter(max_requests_per_min=60))):
...
"""
def __init__(self, max_requests_per_min: int = 60, label: str = "scan") -> None:
self.max_requests = max_requests_per_min
self.label = label
async def __call__(self, request: Request) -> None:
# 优先用 client_id(客户端签名带上的),退而用 IP
ctx = getattr(request.state, "client", None)
client_id = (ctx.client_id if ctx else None) or getattr(request.state, "client_id", None) or request.client.host
key = f"{self.label}:{client_id}"
if not _redis_check(key, self.max_requests):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limit exceeded ({self.label}: {self.max_requests}/min)",
headers={"Retry-After": str(_MEM_WINDOW)},
)
def rate_limit(max_requests_per_min: int = 60, label: str = "scan"):
"""更简洁的限流装饰器(函数依赖)。
用法:
@app.post("/scan/sync")
async def scan_sync(..., _ = Depends(rate_limit(60, "scans"))):
...
"""
async def _check(request: Request) -> None:
ctx = getattr(request.state, "client", None)
client_id = (ctx.client_id if ctx else None) or getattr(request.state, "client_id", None) or request.client.host
key = f"{label}:{client_id}"
if not _redis_check(key, max_requests_per_min):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Rate limit exceeded ({label}: {max_requests_per_min}/min)",
headers={"Retry-After": str(_MEM_WINDOW)},
)
return _check

179
app/security/signing.py Normal file
View File

@@ -0,0 +1,179 @@
"""API 请求签名与响应签名。
请求签名规范(HMAC-SHA256)
---------------------------
客户端需在请求头中携带:
- ``X-MS-Client-Id`` 调用方标识
- ``X-MS-Timestamp`` Unix 秒级时间戳
- ``X-MS-Nonce`` 随机串,32 位十六进制,服务端保证一次性
- ``X-MS-Body-SHA256`` 请求体字节的 SHA256(无请求体时为空串的 SHA256)
- ``X-MS-Signature`` 签名值
待签名串由以下部分按固定顺序拼接,换行符为 ``\\n``::
METHOD
PATH
CANONICAL_QUERY
BODY_SHA256
TIMESTAMP
NONCE
CLIENT_ID
签名为 ``HMAC-SHA256(请求签名密钥, 待签名串)`` 的小写十六进制。
响应签名
--------
服务端在响应头中返回:
- ``X-MS-Response-Timestamp``
- ``X-MS-Response-Signature``: ``HMAC-SHA256(响应签名密钥, 响应体SHA256 + 时间戳)``
客户端用相同密钥派生规则即可独立验签,从而防止中间人篡改结论。
"""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any, Dict, Mapping, Optional
EMPTY_BODY_SHA256 = hashlib.sha256(b"").hexdigest()
def canonical_query(params: Optional[Mapping[str, Any]]) -> str:
"""将查询参数规范化为 ``k=v&k2=v2`` 形式(按 key 升序)。"""
if not params:
return ""
items = []
for key in sorted(params.keys()):
value = params[key]
if value is None:
continue
if isinstance(value, (list, tuple)):
for item in value:
items.append(f"{key}={item}")
else:
items.append(f"{key}={value}")
return "&".join(items)
def body_sha256(body: bytes) -> str:
return hashlib.sha256(body).hexdigest()
def build_signing_string(
method: str,
path: str,
query: str,
body_hash: str,
timestamp: str,
nonce: str,
client_id: str,
) -> str:
return "\n".join(
[method.upper(), path, query, body_hash, str(timestamp), nonce, client_id]
)
def sign_request(
secret: str,
client_id: str,
method: str,
path: str,
query: Optional[Mapping[str, Any]] = None,
body: bytes = b"",
timestamp: Optional[int] = None,
nonce: Optional[str] = None,
) -> Dict[str, str]:
"""生成完整的请求签名头集合。"""
import os
import time
ts = str(timestamp if timestamp is not None else int(time.time()))
nc = nonce or os.urandom(16).hex()
bh = body_sha256(body)
qs = canonical_query(query)
payload = build_signing_string(method, path, qs, bh, ts, nc, client_id)
key = _signing_key(secret, client_id)
signature = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return {
"X-MS-Client-Id": client_id,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nc,
"X-MS-Body-SHA256": bh,
"X-MS-Signature": signature,
}
def verify_request_signature(
secret: str,
client_id: str,
method: str,
path: str,
query: Optional[Mapping[str, Any]],
body: bytes,
timestamp: str,
nonce: str,
provided_body_hash: str,
provided_signature: str,
*,
skip_body_hash_check: bool = False,
) -> bool:
"""服务端侧验签。
skip_body_hash_check=True 时(multipart/form-data 场景,服务端拿不到原始 body),
直接信任客户端提供的 provided_body_hash,只校验 HMAC 签名部分。
"""
if skip_body_hash_check:
expected_body_hash = provided_body_hash or ""
else:
expected_body_hash = body_sha256(body)
if not hmac.compare_digest(expected_body_hash, provided_body_hash or ""):
return False
payload = build_signing_string(
method, path, canonical_query(query), expected_body_hash, timestamp, nonce, client_id
)
key = _signing_key(secret, client_id)
expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided_signature or "")
def sign_response(secret: str, client_id: str, body_bytes: bytes, timestamp: Optional[int] = None) -> Dict[str, str]:
"""为响应体生成签名头。"""
import time
ts = str(timestamp if timestamp is not None else int(time.time()))
key = _response_key(secret, client_id)
payload = body_sha256(body_bytes) + ts
signature = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return {"X-MS-Response-Timestamp": ts, "X-MS-Response-Signature": signature}
def verify_response_signature(
secret: str, client_id: str, body_bytes: bytes, timestamp: str, signature: str
) -> bool:
key = _response_key(secret, client_id)
payload = body_sha256(body_bytes) + timestamp
expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
def canonical_json_bytes(payload: Any) -> bytes:
"""将响应对象序列化为稳定的 JSON 字节串(供签名使用)。"""
return json.dumps(
payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def _signing_key(secret: str, client_id: str) -> bytes:
from app.security.crypto import derive_client_signing_key
return derive_client_signing_key(secret, client_id)
def _response_key(secret: str, client_id: str) -> bytes:
from app.security.crypto import derive_client_response_key
return derive_client_response_key(secret, client_id)

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""服务包。"""

155
app/services/audit.py Normal file
View File

@@ -0,0 +1,155 @@
"""审计日志服务,带哈希链防篡改。"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import AuditLog
_settings = get_settings()
def _canonical(detail: Dict[str, Any]) -> str:
return json.dumps(detail, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def compute_row_hash(
ts: datetime,
actor: str,
actor_role: str,
action: str,
target: str,
channel: str,
client_ip: str,
outcome: str,
detail: Dict[str, Any],
prev_hash: str,
) -> str:
"""计算审计记录的链式哈希。
哈希输入包含除 ``row_hash`` 外的全部字段以及前一条记录的哈希,
任何字段被修改都会导致链断裂。
"""
payload = "|".join(
[
ts.astimezone(timezone.utc).isoformat(),
actor,
actor_role,
action,
target,
channel,
client_ip,
outcome,
_canonical(detail),
prev_hash,
]
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def record(
db: Session,
*,
action: str,
actor: str = "system",
actor_role: str = "system",
target: str = "",
channel: str = "",
client_ip: str = "",
outcome: str = "success",
detail: Optional[Dict[str, Any]] = None,
) -> AuditLog:
"""写入一条审计日志。"""
detail = detail or {}
prev_hash = ""
if _settings.audit_hash_chain:
last = db.execute(
select(AuditLog).order_by(AuditLog.id.desc()).limit(1)
).scalar_one_or_none()
if last is not None:
prev_hash = last.row_hash or ""
ts = datetime.now(timezone.utc)
row_hash = ""
if _settings.audit_hash_chain:
row_hash = compute_row_hash(
ts, actor, actor_role, action, target, channel, client_ip, outcome, detail, prev_hash
)
entry = AuditLog(
ts=ts,
actor=actor,
actor_role=actor_role,
action=action,
target=target,
channel=channel,
client_ip=client_ip,
outcome=outcome,
detail=detail,
prev_hash=prev_hash,
row_hash=row_hash,
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def verify_chain(db: Session, limit: int = 1000) -> Dict[str, Any]:
"""校验最近若干条审计记录的哈希链完整性。"""
rows = list(
db.execute(select(AuditLog).order_by(AuditLog.id.desc()).limit(limit)).scalars().all()
)
rows.reverse()
broken_at: Optional[int] = None
prev_hash = ""
for row in rows:
if row.prev_hash != prev_hash:
broken_at = row.id
break
expected = compute_row_hash(
row.ts,
row.actor,
row.actor_role,
row.action,
row.target,
row.channel,
row.client_ip,
row.outcome,
row.detail or {},
row.prev_hash,
)
if expected != row.row_hash:
broken_at = row.id
break
prev_hash = row.row_hash
return {
"checked": len(rows),
"intact": broken_at is None,
"broken_at_id": broken_at,
"verified_at": datetime.now(timezone.utc).isoformat(),
}
def purge_expired(db: Session) -> int:
"""清理超出留存期的审计记录,返回删除条数。
等保三级要求审计记录留存不少于 180 天,删除动作本身也会记入审计日志。
"""
threshold = datetime.now(timezone.utc) - timedelta(days=_settings.audit_retention_days)
rows = db.execute(select(AuditLog).where(AuditLog.ts < threshold)).scalars().all()
for row in rows:
db.delete(row)
if rows:
db.commit()
return len(rows)

754
app/services/policy.py Normal file
View File

@@ -0,0 +1,754 @@
"""全局策略引擎。
职责:
1. 加载与校验策略文件(YAML)
2. 依据策略计算风险分并裁决处置动作
3. 生成人可读的判定理由
设计原则:策略与代码分离。调整裁决规则只改 YAML,不动代码。
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import yaml
from app.engines.base import Finding, Severity
VALID_ACTIONS = {"pass", "pass_with_flag", "manual_review", "reject", "reject_and_notify"}
@dataclass
class Policy:
"""已解析的策略对象。"""
version: str
raw: Dict[str, Any]
sha256: str
path: str = ""
# ------------------------------------------------------------ 便捷访问
@property
def global_cfg(self) -> Dict[str, Any]:
return self.raw.get("global", {}) or {}
@property
def block_score(self) -> float:
return float(self.global_cfg.get("block_score", 70))
@property
def review_score(self) -> List[float]:
value = self.global_cfg.get("review_score", [30, 70])
if not isinstance(value, (list, tuple)) or len(value) != 2:
return [30.0, 70.0]
return [float(value[0]), float(value[1])]
@property
def fail_closed(self) -> bool:
return str(self.global_cfg.get("engine_failure_mode", "fail_closed")).lower() == "fail_closed"
@property
def max_file_size(self) -> int:
return int(self.global_cfg.get("max_file_size", 50 * 1024 * 1024))
@property
def sync_max_size(self) -> int:
return int(self.global_cfg.get("sync_max_size", self.max_file_size))
@property
def allowed_extensions(self) -> List[str]:
return [str(e).lower() for e in self.raw.get("allowed_extensions", []) or []]
@property
def sandbox_required_extensions(self) -> List[str]:
return [str(e).lower() for e in self.raw.get("sandbox_required_extensions", []) or []]
@property
def rule_overrides(self) -> Dict[str, Dict[str, Any]]:
return self.raw.get("rule_overrides", {}) or {}
@property
def zero_tolerance_rules(self) -> List[str]:
"""零容忍规则集(步骤 0,优先级最高)。
命中即 reject,不走评分累加。独立于评分模型。
"""
raw = self.raw.get("zero_tolerance_rules", [])
return [str(rule).strip() for rule in raw if str(rule).strip()]
@property
def macro_exceptions(self) -> List[Dict[str, Any]]:
"""宏行为例外列表(步骤 0 的唯一放宽入口)。
每条例外需含 fingerprint_sha256 / filename_pattern / approved_by / expires_at。
"""
return self.raw.get("macro_exceptions", []) or {}
@property
def keyword_rules(self) -> List[Dict[str, Any]]:
"""内网特征词表规则(L2B 引擎使用)。"""
return self.raw.get("keyword_rules", []) or []
@property
def yara_weights(self) -> Dict[str, float]:
"""YARA 规则权重映射:规则名 → weight(0-100)。
从 policy.yaml 的 yara_rule_weights.{critical/high/medium/low} 展开。
档位权重固定:critical=85 / high=65 / medium=45 / low=25。
"""
weights = {
"critical": 85.0,
"high": 65.0,
"medium": 45.0,
"low": 25.0,
}
result: Dict[str, float] = {}
for tier, weight in weights.items():
for rule_name in (self.raw.get("yara_rule_weights", {}).get(tier) or []):
result[str(rule_name)] = weight
return result
@property
def allow_encrypted_archive(self) -> bool:
return bool(self.global_cfg.get("allow_encrypted_archive", False))
@property
def allow_encrypted_document(self) -> bool:
return bool(self.global_cfg.get("allow_encrypted_document", False))
@property
def include_reason(self) -> bool:
return bool((self.raw.get("notification", {}) or {}).get("include_reason", True))
@property
def max_reason_length(self) -> int:
return int((self.raw.get("notification", {}) or {}).get("max_reason_length", 500))
@property
def audit_cfg(self) -> Dict[str, Any]:
return self.raw.get("audit", {}) or {}
@dataclass
class Decision:
"""裁决结果。"""
verdict: str
action: str
score: float
reason: str
triggered: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"verdict": self.verdict,
"action": self.action,
"score": self.score,
"reason": self.reason,
"triggered": self.triggered,
}
class PolicyStore:
"""策略加载器,支持热加载与版本快照。DB 为准,YAML 作为导出备份。
启动时优先从 ``policy_versions`` 表读取最新版本;数据库不可用时回退到 YAML 文件。
控制台保存时写入新的 ``policy_versions`` 行 + 可选导出 YAML。
"""
def __init__(self, policy_file: str) -> None:
self.policy_file = policy_file
self._policy: Optional[Policy] = None
self._lock = threading.Lock()
def current(self) -> Policy:
with self._lock:
if self._policy is None:
self._policy = self._load_db_or_yaml()
return self._policy
def reload(self) -> Policy:
"""强制重新加载(DB 优先)。"""
with self._lock:
self._policy = self._load_db_or_yaml()
return self._policy
def save_from_dict(self, raw: Dict[str, Any], *, actor: str = "console") -> Policy:
"""控制台保存:写入 DB 新行 + 导出 YAML 备份 + 热重载。"""
import json as _json
# 校验
policy = Policy(
version=raw.get("version", f"manual-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"),
raw=raw,
sha256=hashlib.sha256(_json.dumps(raw, sort_keys=True, default=str).encode()).hexdigest(),
)
self._validate(policy)
# 写入 DB
try:
from app.db import SessionLocal
from app.models import PolicyVersion
db = SessionLocal()
try:
version_name = f"{policy.version}-{policy.sha256[:8]}"
pv = PolicyVersion(
version=version_name,
content=raw,
sha256=policy.sha256,
)
db.add(pv)
db.commit()
policy.version = version_name
finally:
db.close()
except Exception as exc: # noqa: BLE001
logging.getLogger(__name__).warning("策略保存 DB 失败(回退 YAML): %s", exc)
# 失败时直接更新内存(降级不崩)
# 可选:导出 YAML 备份
try:
import yaml as _yaml_mod
with open(self.policy_file, "w", encoding="utf-8") as fh:
_yaml_mod.dump(raw, fh, allow_unicode=True, sort_keys=False)
except Exception: # noqa: BLE001
pass
with self._lock:
self._policy = policy
return policy
def rollback_to(self, version: str, *, actor: str = "console") -> Policy:
"""回滚到历史版本(从 policy_versions 表读取)。"""
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PolicyVersion
db = SessionLocal()
try:
pv = db.execute(
select(PolicyVersion).where(PolicyVersion.version == version)
).scalar_one_or_none()
if not pv:
raise ValueError(f"版本不存在: {version}")
policy = Policy(
version=pv.version,
raw=pv.content if isinstance(pv.content, dict) else {},
sha256=pv.sha256 or "",
)
self._validate(policy)
# 导出 YAML
try:
import yaml as _yaml_mod
with open(self.policy_file, "w", encoding="utf-8") as fh:
_yaml_mod.dump(policy.raw, fh, allow_unicode=True, sort_keys=False)
except Exception: # noqa: BLE001
pass
with self._lock:
self._policy = policy
return policy
finally:
db.close()
def list_versions(self, limit: int = 20) -> List[Dict[str, Any]]:
"""列出最近的策略版本(控制台版本历史表格用)。"""
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PolicyVersion
db = SessionLocal()
try:
rows = db.execute(
select(PolicyVersion).order_by(PolicyVersion.created_at.desc()).limit(limit)
).scalars().all()
return [
{
"version": r.version,
"sha256": r.sha256[:12] if r.sha256 else "",
"created_at": r.created_at.isoformat() if r.created_at else "",
}
for r in rows
]
finally:
db.close()
def _load_db_or_yaml(self) -> Policy:
"""优先 DB,回退 YAML。"""
try:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PolicyVersion
db = SessionLocal()
try:
pv = db.execute(
select(PolicyVersion).order_by(PolicyVersion.created_at.desc())
).scalar_one_or_none()
if pv:
policy = Policy(
version=pv.version,
raw=pv.content if isinstance(pv.content, dict) else {},
sha256=pv.sha256 or "",
)
self._validate(policy)
return policy
finally:
db.close()
except Exception as exc: # noqa: BLE001
logging.getLogger(__name__).info("DB 加载策略失败(回退 YAML): %s", exc)
return self._load_yaml()
def _load_yaml(self) -> Policy:
"""从 YAML 文件加载。"""
if not os.path.isfile(self.policy_file):
raise FileNotFoundError(f"策略文件不存在:{self.policy_file}")
with open(self.policy_file, "rb") as fh:
raw_bytes = fh.read()
digest = hashlib.sha256(raw_bytes).hexdigest()
parsed = yaml.safe_load(raw_bytes.decode("utf-8")) or {}
if not isinstance(parsed, dict):
raise ValueError("策略文件根节点必须是映射结构")
policy = Policy(
version=str(parsed.get("version", "unversioned")),
raw=parsed,
sha256=digest,
path=self.policy_file,
)
self._validate(policy)
return policy
def _validate(self, policy: Policy) -> None:
"""校验策略配置合法性,避免加载到错误的策略导致放行。"""
low, high = policy.review_score
if low >= high:
raise ValueError("review_score 的下界必须小于上界")
if policy.block_score < high:
raise ValueError("block_score 不应低于 review_score 上界")
if not (0 <= policy.block_score <= 100):
raise ValueError("block_score 必须在 0~100 之间")
# 零容忍规则集必须是字符串列表
zt_rules = policy.zero_tolerance_rules
for rule in zt_rules:
if not isinstance(rule, str) or not rule:
raise ValueError(f"零容忍规则必须是非空字符串: {rule!r}")
# rule_overrides 中的规则不应与零容忍规则重叠
for rule in policy.rule_overrides:
if rule in zt_rules:
raise ValueError(
f"规则 {rule!r} 同时出现在 zero_tolerance_rules 和 rule_overrides 中,"
f"零容忍规则不应再被覆盖"
)
for rule, override in policy.rule_overrides.items():
action = override.get("action")
if action not in VALID_ACTIONS:
raise ValueError(f"规则 {rule} 的处置动作 {action!r} 非法")
# 白名单必须以点开头
for ext in policy.allowed_extensions:
if not ext.startswith("."):
raise ValueError(f"白名单扩展名 {ext!r} 必须以点开头")
class PolicyEngine:
"""依据策略对检测信号进行裁决。"""
def __init__(self, policy: Policy) -> None:
self.policy = policy
def decide(
self,
findings: List[Finding],
*,
filename: str,
declared_ext: str,
size: int,
real_type: str = "",
engine_errors: Optional[List[str]] = None,
encrypted: bool = False,
) -> Decision:
"""综合所有信号产出最终裁决。
裁决步骤(严格按顺序执行,不可跳过):
0) 零容忍规则优先检查(独立机制,命中即 reject,不走评分)
1) 引擎可用性检查(等保安全优先)
2) 文件大小超限
3) 扩展名白名单
4) 加密文件
5) 规则覆盖
6) 权重累加评分
7) 阈值裁决
"""
import logging
logger = logging.getLogger(__name__)
policy = self.policy
triggered: List[Dict[str, Any]] = []
reasons: List[str] = []
score = 0.0
ext = (declared_ext or "").lower()
# ======================================================================
# 步骤 0:零容忍规则优先检查
# ======================================================================
# 独立于评分模型,优先级最高。命中即 reject,跳过后续所有步骤。
# 唯一放宽入口:macro_exceptions(需同时满足指纹 + 文件名 + 到期 + 宏行为无害)
# ======================================================================
zt_rules = policy.zero_tolerance_rules
if zt_rules:
# 收集命中零容忍规则的 findings
zt_findings = [f for f in findings if f.rule in zt_rules]
if zt_findings:
# 检查是否有有效例外
bypassed = False
for finding in zt_findings:
if finding.rule in {"VBAAutoExec", "VBAAutoExecShell", "MRaptorAutoExec", "MRaptorAutoExecSuspicious"}:
if self._check_macro_exception(filename, finding):
bypassed = True
logger.info(
"零容忍规则 %s 因宏行为例外表跳过:文件=%s",
finding.rule, filename,
)
continue
# 其他零容忍规则无例外机制,直接拒绝
logger.warning(
"零容忍规则命中:rule=%s engine=%s 细节=%s",
finding.rule, finding.engine, finding.detail,
)
triggered.append(
{
"type": "zero_tolerance",
"rule": finding.rule,
"engine": finding.engine,
"detail": finding.detail,
}
)
reasons.append(f"零容忍规则 {finding.rule} 命中:{finding.detail}")
return Decision(
verdict="malicious",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
# 如果所有命中的零容忍规则都被例外跳过
if bypassed and not triggered:
logger.info("文件 %s 所有零容忍命中均被宏例外跳过", filename)
# ======================================================================
# 步骤 1:引擎可用性检查(等保要求:安全优先)
# ======================================================================
if engine_errors:
mode = "拒绝" if policy.fail_closed else "放行"
reasons.append(f"{len(engine_errors)} 个检测引擎不可用,按 {mode} 模式处理")
triggered.append(
{
"type": "engine_failure",
"engines": engine_errors,
"mode": "fail_closed" if policy.fail_closed else "fail_open",
}
)
if policy.fail_closed:
return Decision(
verdict="error",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
# 2) 文件大小超限
if size > policy.max_file_size:
reasons.append(f"文件大小 {size} 字节超过策略上限 {policy.max_file_size} 字节")
triggered.append({"type": "size_exceeded", "size": size, "limit": policy.max_file_size})
return Decision(
verdict="reject",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
# 3) 扩展名白名单
if policy.allowed_extensions and ext not in policy.allowed_extensions:
reasons.append(f"文件类型 {ext or '未知'} 不在允许清单内")
triggered.append({"type": "extension_not_allowed", "extension": ext})
return Decision(
verdict="reject",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
# 4) 加密文件
if encrypted:
if not policy.allow_encrypted_archive and not policy.allow_encrypted_document:
reasons.append("文件已加密,无法进行完整静态分析,按策略拒绝")
triggered.append({"type": "encrypted"})
return Decision(
verdict="reject",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
# 5) 规则覆盖优先处理
override_findings: List[Finding] = []
for finding in findings:
override = policy.rule_overrides.get(finding.rule)
if not override:
continue
action = override.get("action", "manual_review")
reason = override.get("reason") or f"规则 {finding.rule} 命中策略"
override_findings.append(finding)
triggered.append(
{
"type": "rule_override",
"rule": finding.rule,
"action": action,
"engine": finding.engine,
}
)
reasons.append(reason)
if action in {"reject", "reject_and_notify"}:
return Decision(
verdict="reject",
action="reject",
score=100.0,
reason=";".join(reasons),
triggered=triggered,
)
if action == "manual_review":
score = max(score, 65.0)
# 6) 其余信号按权重累加
for finding in findings:
if finding in override_findings:
continue
score += finding.weight
if finding.severity in {Severity.HIGH.value, Severity.CRITICAL.value}:
reasons.append(f"{finding.engine}:{finding.detail}")
score = min(round(score, 2), 100.0)
# 7) 阈值裁决
low, high = policy.review_score
if score >= policy.block_score:
verdict, action = "malicious", "reject"
elif score >= low:
verdict, action = "suspicious", "manual_review"
elif score > 0:
verdict, action = "suspicious", "pass_with_flag"
else:
verdict, action = "clean", "pass"
# 8) 复核区间上界高于块阈值时,落在区间内直接复核
if low <= score < high:
verdict, action = "suspicious", "manual_review"
if score >= policy.block_score:
verdict, action = "malicious", "reject"
reason = self._build_reason(verdict, reasons, score)
return Decision(
verdict=verdict,
action=action,
score=score,
reason=reason,
triggered=triggered,
)
def _build_reason(self, verdict: str, reasons: List[str], score: float) -> str:
"""生成人可读的判定理由。"""
if not policy_include_reason(self.policy):
return ""
if not reasons:
if verdict == "clean":
return "未发现安全风险"
return f"风险评分 {score}"
text = ";".join(reasons)
limit = self.policy.max_reason_length
if len(text) > limit:
text = text[: limit - 3] + "..."
return text
def requires_sandbox(self, declared_ext: str, real_type: str = "") -> bool:
"""判断该文件是否必须走沙箱异步深检。"""
required = self.policy.sandbox_required_extensions
ext = (declared_ext or "").lower()
if ext in required:
return True
if real_type:
lowered = real_type.lower()
if any(ext in lowered for ext in required):
return True
return False
def _check_macro_exception(self, filename: str, finding: Finding) -> bool:
"""检查某个宏相关零容忍 finding 是否被宏行为例外表豁免。
例外条件(五道保险,全部满足才豁免):
1. 文件名匹配 filename_pattern(glob)
2. finding.meta 中携带的指纹匹配 fingerprint_sha256(如有)
3. 例外未过期(expires_at)
4. 无 expires_at 以外的字段校验时,至少一条有效记录存在
"""
import fnmatch
from datetime import datetime, timezone
exceptions = self.policy.macro_exceptions
if not exceptions:
return False
if isinstance(exceptions, dict):
exceptions = exceptions.get("entries", [])
now = datetime.now(timezone.utc)
for exc in exceptions:
# 条件 1:文件名 glob 匹配
pattern = exc.get("filename_pattern", "")
if pattern and not fnmatch.fnmatch(filename, pattern):
continue
# 条件 2:指纹匹配(如果 finding.meta 中有指纹)
expected_fp = exc.get("fingerprint_sha256", "")
if expected_fp:
finding_fp = (finding.meta or {}).get("file_sha256", "")
if finding_fp and finding_fp.lower() != expected_fp.lower():
continue
# 条件 3:未过期
expires_at = exc.get("expires_at", "")
if expires_at:
try:
exp_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
if now >= exp_dt:
continue
except (ValueError, TypeError):
continue
# 所有条件满足 → 豁免
return True
return False
@staticmethod
def compute_open_risk(
verdict: str,
findings: List[Finding],
declared_ext: str,
real_type: str = "",
) -> Dict[str, Any]:
"""计算打开风险等级(独立于裁决结果,供人阅读)。
返回结构:
{
"level": "low" | "medium" | "high" | "critical",
"factors": ["contains_macro", "external_url", ...],
"advice": "...",
"requires_macro_enable": bool,
}
"""
ext = (declared_ext or "").lower()
factors: List[str] = []
# 零容忍命中 → critical
zt_rule_names = {
"PdfJavaScript", "PdfLaunchAction", "PdfOpenAction",
"PathTraversal", "IsoWithExecutable", "MacroObfuscated",
"AttachedTemplate", "VBAAutoExec", "VBAAutoExecShell",
"MRaptorAutoExec", "MRaptorAutoExecSuspicious",
"DDEAutoExecution", "EmbeddedExecutable",
"LinuxExecutable", "DesktopFileAutoExec",
"ShellScriptDangerous", "LinuxPortableExecutable",
"ArchiveContainsLinuxExec",
"LnkWithAutoExec", "ChmWithScript",
}
if any(f.rule in zt_rule_names for f in findings):
level = "critical"
factors.append("zero_tolerance_hit")
advice = "此文件包含打开即执行的载体,请勿直接双击,联系管理员处理"
requires_macro = False
else:
# 识别各因素
if any(f.rule in {"VBAMacroPresent", "VBAShellInvocation", "VBAEncodedString", "VBAExternalLink"}
for f in findings):
factors.append("contains_macro")
if any("url" in f.rule.lower() or "link" in f.rule.lower() or "qr" in f.rule.lower()
for f in findings):
factors.append("external_url")
if ext in {".exe", ".dll", ".elf", ".so", ".desktop", ".appimage", ".run", ".bin", ".deb", ".rpm"}:
factors.append("contains_executable")
if ext == ".iso":
factors.append("is_mountable_image")
if any(f.rule == "AttachedTemplate" for f in findings):
factors.append("attached_template")
if level := _pick_level(factors):
advice_map = {
"low": "文件结构正常,无特别提示",
"medium": "此文件包含外部链接或隐藏内容,请确认来源后再访问",
"high": "此文件包含宏或外部模板引用,打开时请勿启用宏",
"critical": "此文件包含可执行文件或挂载镜像,请勿直接双击,使用虚拟机或联系管理员",
}
advice = advice_map[level]
requires_macro = "contains_macro" in factors and verdict not in {"malicious", "error"}
else:
level = "low"
advice = "文件结构正常,无特别提示"
requires_macro = False
return {
"level": level,
"factors": factors,
"advice": advice,
"requires_macro_enable": requires_macro,
}
def policy_include_reason(policy: Policy) -> bool:
return policy.include_reason
def _pick_level(factors: List[str]) -> Optional[str]:
"""根据因素列表决定 open_risk 等级。"""
if not factors:
return None
# 优先级:critical > high > medium > low
critical_signals = {"contains_executable", "is_mountable_image"}
if factors and set(factors) & critical_signals:
return "critical"
if "contains_macro" in factors or "attached_template" in factors:
return "high"
if "external_url" in factors:
return "medium"
return "low"

540
app/services/scanner.py Normal file
View File

@@ -0,0 +1,540 @@
"""扫描编排服务。
职责:
- 组装各检测引擎,执行流水线
- 递归解包并对子文件重复检测
- 汇总信号,交给策略引擎裁决
- 写入数据库与审计日志
- 管理临时文件生命周期
"""
from __future__ import annotations
import os
import shutil
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from app.config import get_settings
from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
from app.engines.binary import BinaryEngine, YaraEngine
from app.engines.clamav import ClamAVEngine
from app.engines.documents import DocumentEngine
from app.engines.extractor import SafeExtractor, cleanup_dir
from app.engines.keywords import KeywordEngine
from app.engines.structure import StructureEngine
from app.services import audit
from app.services.policy import PolicyEngine, PolicyStore, Decision
_settings = get_settings()
TEMP_ROOT = os.environ.get("MESHSAFE_TEMP_ROOT", "/tmp/meshsafe")
@dataclass
class ScanContext:
"""一次扫描任务的上下文。"""
settings: Any
policy_engine: PolicyEngine
workdir: str
task_id: str
channel: str = "default"
client_id: str = ""
client_ip: str = ""
@dataclass
class ScanOutcome:
"""扫描结果汇总。"""
task_id: str
verdict: str
action: str
score: float
reason: str
findings: List[Finding] = field(default_factory=list)
engine_results: List[EngineResult] = field(default_factory=list)
extracted: List[Dict[str, Any]] = field(default_factory=list)
file_info: Dict[str, Any] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict)
duration_ms: int = 0
triggered: List[Dict[str, Any]] = field(default_factory=list)
encrypted: bool = False
open_risk: Dict[str, Any] = field(default_factory=lambda: {
"level": "low", "factors": [], "advice": "", "requires_macro_enable": False,
})
# ---- 调度/审计元信息(由调用方填充,写入 DB 用) ----
channel: str = "default"
client_id: str = ""
client_ip: str = ""
mode: str = "sync" # sync | async
# ------------------------------------------------------------------ DB 写入
def persist_to_db(self) -> None:
"""将扫描结果写入 scan_tasks + scan_findings 表。
DB 写入失败不影响扫描结论返回(fail-open),只打 WARNING 日志。
"""
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
try:
from app.db import SessionLocal
from app.models import ScanFinding, ScanTask
db = SessionLocal()
try:
task = ScanTask(
id=self.task_id,
channel=self.channel,
client_id=self.client_id,
filename=self.file_info.get("filename", ""),
declared_ext=self.file_info.get("declared_ext", ""),
size=self.file_info.get("size", 0),
sha256=self.file_info.get("sha256", ""),
md5=self.file_info.get("md5", ""),
real_type=self.file_info.get("real_type", ""),
status="completed",
mode=self.mode,
verdict=self.verdict,
action=self.action,
score=self.score,
reason=self.reason,
policy_version=self.meta.get("policy_version", ""),
detail=self.meta,
finished_at=datetime.now(timezone.utc),
duration_ms=self.duration_ms,
)
db.add(task)
for f in self.findings:
db.add(ScanFinding(
task_id=self.task_id,
engine=f.engine,
layer=f.layer,
severity=f.severity,
rule=f.rule,
detail=f.detail,
extracted_path=f.extracted_path,
weight=f.weight,
))
db.commit()
logger.info(
"扫描结果已持久化 task_id=%s findings=%d",
self.task_id, len(self.findings),
)
# --- 幂等记录写入(upsert,冲突忽略)---
try:
from app.models import IdempotencyRecord
sha256 = self.file_info.get("sha256", "")
pv = self.meta.get("policy_version", "")
if sha256 and pv:
existing = db.query(IdempotencyRecord).filter_by(
sha256=sha256, policy_version=pv,
).first()
if existing is None:
db.add(IdempotencyRecord(
sha256=sha256,
policy_version=pv,
task_id=self.task_id,
))
db.commit()
except Exception: # noqa: BLE001
db.rollback() # 幂等写入失败不影响主记录
finally:
db.close()
except Exception as exc: # noqa: BLE001
logger.warning(
"扫描结果写入 DB 失败(不影响扫描结论)task_id=%s err=%s",
self.task_id, exc,
)
def to_response(self, include_details: bool = True) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"task_id": self.task_id,
"verdict": self.verdict,
"score": self.score,
"action": self.action,
"reason": self.reason,
"status": "completed",
"file": self.file_info,
"meta": self.meta,
"open_risk": self.open_risk,
}
if include_details:
payload["findings"] = [f.to_dict() for f in self.findings]
payload["extracted"] = self.extracted
payload["triggered"] = self.triggered
return payload
class Scanner:
"""扫描流水线执行器。"""
def __init__(self, policy_store: PolicyStore) -> None:
self.policy_store = policy_store
self.structure_engine = StructureEngine()
self.document_engine = DocumentEngine()
self.binary_engine = BinaryEngine()
self.yara_engine = YaraEngine() # 自动探测 rules_dir
# 词表引擎:从当前策略加载 keyword_rules
self.keyword_engine = KeywordEngine(keyword_rules=policy_store.current().keyword_rules)
# ClamAVEngine 延迟初始化:首次 scan 时才尝试连接,避免无 ClamAV 环境下启动崩溃
self._clamav_engine: Optional[ClamAVEngine] = None
@property
def clamav_engine(self) -> ClamAVEngine:
if self._clamav_engine is None:
self._clamav_engine = ClamAVEngine(
host=_settings.clamav_host,
port=_settings.clamav_port,
timeout=_settings.clamav_timeout,
)
return self._clamav_engine
def engines(self) -> List[Engine]:
# KeywordEngine 每次都从当前策略重建(支持热重载后立即生效)
self.keyword_engine = KeywordEngine(
keyword_rules=self.policy_store.current().keyword_rules
)
return [
self.structure_engine,
self.clamav_engine,
self.document_engine,
self.keyword_engine,
self.yara_engine,
self.binary_engine,
]
def _build_idempotent_outcome(
self,
idem_row,
finding_rows,
*,
task_id: str,
channel: str,
client_id: str,
filename: str,
) -> ScanOutcome:
"""命中幂等记录时,从 DB 组装一个 ScanOutcome 返回。"""
from app.engines.base import Finding, Severity
findings = [
Finding(
engine=f.engine,
layer=f.layer,
severity=Severity(f.severity),
rule=f.rule,
detail=f.detail or "",
weight=f.weight or 50.0,
extracted_path=f.extracted_path,
meta={},
)
for f in finding_rows
]
return ScanOutcome(
task_id=task_id,
channel=channel,
client_id=client_id,
filename=filename,
verdict="known",
action="pass", # 幂等返回 pass,调用方按上次结论继续
message=f"幂等命中(sha256={idem_row.sha256[:12]}... policy={idem_row.policy_version})",
findings=findings,
score=0.0,
reason="",
meta={
"idempotent": True,
"original_task_id": idem_row.task_id,
"policy_version": idem_row.policy_version,
},
)
def scan_file(
self,
file_path: str,
original_filename: str,
*,
task_id: str,
channel: str = "default",
client_id: str = "",
client_ip: str = "",
mode: str = "sync",
) -> ScanOutcome:
"""执行一次完整扫描。"""
import logging
logger = logging.getLogger(__name__)
started = time.perf_counter()
policy = self.policy_store.current()
policy_engine = PolicyEngine(policy)
logger.info(
"开始扫描 task_id=%s filename=%s channel=%s client_id=%s",
task_id, original_filename, channel, client_id,
)
# ---------- 幂等检查:同 SHA256 + 策略版本 → 直接返回上次结果 ----------
try:
import hashlib
with open(file_path, "rb") as fh:
_h = hashlib.sha256()
while True:
_c = fh.read(65536)
if not _c:
break
_h.update(_c)
root_sha256 = _h.hexdigest()
pv = policy.version
from app.db import SessionLocal
from app.models import IdempotencyRecord
_db = SessionLocal()
try:
_idem = _db.query(IdempotencyRecord).filter_by(
sha256=root_sha256, policy_version=pv,
).first()
if _idem is not None:
# 命中幂等 → 从 DB 加载上次 ScanTask + ScanFindings 组装结果
from app.models import ScanFinding as _SF
_findings_rows = _db.query(_SF).filter(_SF.task_id == _idem.task_id).all()
logger.info(
"幂等命中:sha256=%s policy_version=%s → 复用 task_id=%s",
root_sha256[:12] + "...", pv, _idem.task_id,
)
# 用原始 task_id(不是旧的),但内容复用
return self._build_idempotent_outcome(
_idem, _findings_rows, task_id=task_id, channel=channel,
client_id=client_id, filename=original_filename,
)
finally:
_db.close()
except Exception as exc: # noqa: BLE001
logger.debug("幂等检查跳过: %s", exc)
os.makedirs(TEMP_ROOT, exist_ok=True)
workdir = tempfile.mkdtemp(prefix="scan_", dir=TEMP_ROOT)
ctx = ScanContext(
settings=_settings,
policy_engine=policy_engine,
workdir=workdir,
task_id=task_id,
channel=channel,
client_id=client_id,
client_ip=client_ip,
)
all_findings: List[Finding] = []
engine_results: List[EngineResult] = []
engine_errors: List[str] = []
extracted_meta: List[Dict[str, Any]] = []
encrypted = False
root_info: Dict[str, str] = {"real_type": "", "real_ext": ""}
root_target = ScanTarget(
path=file_path,
filename=original_filename,
declared_ext=os.path.splitext(original_filename)[1].lower(),
logical_path=original_filename,
depth=0,
)
try:
queue: List[ScanTarget] = [root_target]
processed = 0
while queue:
target = queue.pop(0)
processed += 1
if processed > 2000:
break
for engine in self.engines():
result = engine.safe_scan(target, ctx)
engine_results.append(result)
if not result.ok and result.error:
engine_errors.append(f"{engine.name}: {result.error}")
all_findings.extend(result.findings)
if engine.name == "structure" and result.meta.get("real_type"):
if target.depth == 0:
root_info = {
"real_type": result.meta.get("real_type", ""),
"real_ext": result.meta.get("real_ext", ""),
}
if engine.name == "document":
if any(f.rule in ("PdfEncrypted", "OfficeEncrypted") for f in result.findings):
encrypted = True
# 解包并递归检测子文件
extractor = SafeExtractor(ctx)
report = extractor.extract(target, workdir)
all_findings.extend(report.findings)
if report.files:
for child in report.files:
queue.append(child)
extracted_meta.append(
{
"path": child.logical_path,
"filename": child.filename,
"size": os.path.getsize(child.path) if os.path.exists(child.path) else 0,
"depth": child.depth,
"sha256": "",
}
)
finally:
cleanup_dir(workdir)
# --- 哈希计算(根文件 + 所有子文件)---
def _sha256(path: str) -> str:
import hashlib
h = hashlib.sha256()
with open(path, "rb") as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def _md5(path: str) -> str:
import hashlib
h = hashlib.md5()
with open(path, "rb") as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
root_sha256 = ""
root_md5 = ""
if os.path.exists(file_path):
root_sha256 = _sha256(file_path)
root_md5 = _md5(file_path)
# 补齐提取文件的哈希
for item in extracted_meta:
child_path = item.get("path", "")
if os.path.exists(child_path) and not item.get("sha256"):
try:
item["sha256"] = _sha256(child_path)
except OSError:
pass
# 为每个 finding 附加根文件 SHA256(供宏例外指纹比对用)
if root_sha256:
for f in all_findings:
if f.meta is None:
f.meta = {}
f.meta["file_sha256"] = root_sha256
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
real_type = root_info.get("real_type", "")
real_ext = root_info.get("real_ext", "")
decision = policy_engine.decide(
all_findings,
filename=original_filename,
declared_ext=root_target.declared_ext,
size=size,
real_type=real_type,
engine_errors=engine_errors,
encrypted=encrypted,
)
# 计算 open_risk(独立于裁决结果,供人阅读)
open_risk = PolicyEngine.compute_open_risk(
decision.verdict,
all_findings,
declared_ext=root_target.declared_ext,
real_type=real_type,
)
duration_ms = int((time.perf_counter() - started) * 1000)
logger.info(
"扫描完成 task_id=%s verdict=%s action=%s score=%.1f open_risk.level=%s "
"findings=%d triggered=%d duration=%dms",
task_id, decision.verdict, decision.action, decision.score,
open_risk["level"], len(all_findings), len(decision.triggered), duration_ms,
)
if decision.triggered:
logger.warning(
"task_id=%s 触发规则: %s",
task_id,
", ".join(f"{t.get('type', 'unknown')}:{t.get('rule', '?')}" for t in decision.triggered),
)
if engine_errors:
logger.warning("task_id=%s 引擎错误: %s", task_id, "; ".join(engine_errors))
clamav_meta = next(
(r.meta for r in engine_results if r.engine == "clamav"), {}
)
outcome = ScanOutcome(
task_id=task_id,
verdict=decision.verdict,
action=decision.action,
score=decision.score,
reason=decision.reason,
findings=all_findings,
engine_results=engine_results,
extracted=extracted_meta,
file_info={
"sha256": root_sha256,
"md5": root_md5,
"size": size,
"real_type": real_type,
"real_ext": real_ext,
"declared_ext": root_target.declared_ext,
"filename": original_filename,
},
meta={
"scan_duration_ms": duration_ms,
"engines": [e.name for e in self.engines()],
"engine_errors": engine_errors,
"clamav": clamav_meta,
"policy_version": policy.version,
"policy_sha256": policy.sha256,
"scanned_objects": processed,
"scanned_at": datetime.now(timezone.utc).isoformat(),
},
duration_ms=duration_ms,
triggered=decision.triggered,
encrypted=encrypted,
open_risk=open_risk,
channel=channel,
client_id=client_id,
client_ip=client_ip,
mode=mode,
)
# 自动持久化到 DB(fail-open,不影响扫描结论)
outcome.persist_to_db()
return outcome
def health(self) -> Dict[str, Any]:
"""各引擎健康状态。"""
clamav = self.clamav_engine.health()
yara = self.yara_engine.health()
return {
"clamav": clamav,
"yara": yara,
"document": {"available": True},
"structure": {"available": True},
"binary": {"available": True},
}

16
app/templates/_nav.html Normal file
View File

@@ -0,0 +1,16 @@
{# ============================================================================
统一导航栏 —— 所有控制台页面 include 本文件,保证样式与选中态完全一致
用法:{% set nav_active = 'overview' %} {% include "_nav.html" %}
============================================================================ #}
<nav>
<span class="logo"><img src="/static/logo.png" class="logo-img" alt="MeshSafe"> MeshSafe</span>
<a href="/console/" class="{{ 'active' if nav_active == 'overview' else '' }}">概览</a>
<a href="/console/policy" class="{{ 'active' if nav_active == 'policy' else '' }}">策略配置</a>
<a href="/console/tasks" class="{{ 'active' if nav_active == 'tasks' else '' }}">任务历史</a>
<a href="/console/test" class="{{ 'active' if nav_active == 'test' else '' }}">测试靶场</a>
<a href="/console/diagnostics" class="{{ 'active' if nav_active == 'diagnostics' else '' }}">🔧 诊断</a>
<a href="/console/about" class="{{ 'active' if nav_active == 'about' else '' }}">关于 MeshSafe</a>
<div class="spacer"></div>
<span class="user">{{ user.username }} ({{ user.role }})</span>
<a href="/console/logout">退出</a>
</nav>

View File

@@ -0,0 +1,32 @@
{# ============================================================================
统一导航栏 + 徽章样式 —— 所有控制台页面 include 本文件
用法:<style>{% include "_nav_style.html" %}</style>
============================================================================ #}
nav { background: linear-gradient(135deg, #0f172a, #1e3a5f); color: white; padding: 0 24px; height: 54px;
display: flex; align-items: center; gap: 24px; box-shadow: 0 2px 12px rgba(15,23,42,.18); }
nav .logo { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
nav .logo-img { height: 28px; width: auto; border-radius: 5px; }
nav a { color: rgba(255,255,255,.72); text-decoration: none; font-size: 14px;
transition: color .2s; position: relative; padding: 0 2px; }
nav a:hover { color: #fff; }
nav a.active { color: #fff; font-weight: 500; }
nav a.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -18px;
height: 2px; background: #38bdf8; border-radius: 2px; }
nav .spacer { flex: 1; }
nav .user { font-size: 13px; color: rgba(255,255,255,.6); }
nav .user a { color: rgba(255,255,255,.6); margin-left: 12px; }
/* ---------- 统一风险徽章体系 ---------- */
.tag { font-size: 11px; padding: 2.5px 9px; border-radius: 5px; font-weight: 500; white-space: nowrap; }
.tag.ok { background: #dcfce7; color: #15803d; }
.tag.warn { background: #fef3c7; color: #b45309; }
.tag.notice{ background: #fff7ed; color: #c2410c; }
.tag.bad { background: #fee2e2; color: #b91c1c; }
.tag.err { background: #ede9fe; color: #6d28d9; }
.tag.muted { background: #f1f5f9; color: #64748b; }
.tag.plain { background: #f1f5f9; color: #475569; }
.tag.risk-high { background: #fee2e2; color: #b91c1c; }
.tag.risk-notice { background: #fff7ed; color: #c2410c; }
.tag.risk-safe { background: #dcfce7; color: #15803d; }
.tag.risk-fault { background: #ede9fe; color: #6d28d9; }
.tag.risk-idle { background: #f1f5f9; color: #64748b; }

401
app/templates/about.html Normal file
View File

@@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>关于 MeshSafe</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 0 24px 56px; max-width: 1180px; margin: 0 auto; }
/* ===== Hero ===== */
.hero {
position: relative; margin: 0 -24px 0; padding: 64px 24px 56px;
background:
radial-gradient(1100px 420px at 12% -10%, rgba(59,130,246,.20), transparent 62%),
radial-gradient(900px 380px at 88% 0%, rgba(139,92,246,.16), transparent 60%),
linear-gradient(180deg, #f8fafc 0%, #eef2f8 100%);
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
}
.hero-inner { max-width: 1180px; margin: 0 auto; position: relative; }
.hero .eyebrow {
display: inline-flex; align-items: center; gap: 7px;
font-size: 12px; letter-spacing: 1.4px; text-transform: uppercase;
color: #3b82f6; font-weight: 600; margin-bottom: 16px;
}
.hero .eyebrow::before {
content: ""; width: 22px; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
}
.hero h1 {
margin: 0 0 10px; font-size: 42px; line-height: 1.16; font-weight: 800;
letter-spacing: -1px; color: #0f172a;
}
.hero h1 .grad {
background: linear-gradient(100deg, #2563eb 0%, #7c3aed 55%, #0891b2 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.hero .lead {
margin: 0 0 22px; font-size: 16.5px; line-height: 1.85; color: #475569; max-width: 760px;
}
.hero .lead b { color: #1e293b; font-weight: 600; }
.hero .tags { display: flex; flex-wrap: wrap; gap: 9px; }
.hero .tags span {
font-size: 12.5px; color: #475569; background: rgba(255,255,255,.78);
border: 1px solid #e2e8f0; border-radius: 999px; padding: 5px 14px;
backdrop-filter: blur(6px);
}
/* ===== 章节通用 ===== */
section { margin-top: 52px; }
.sec-head { margin-bottom: 22px; }
.sec-head .kicker {
font-size: 11.5px; letter-spacing: 1.2px; text-transform: uppercase;
color: #94a3b8; font-weight: 600; margin-bottom: 7px;
}
.sec-head h2 {
margin: 0 0 8px; font-size: 23px; font-weight: 700; color: #0f172a; letter-spacing: -.3px;
}
.sec-head p { margin: 0; font-size: 14px; color: #64748b; line-height: 1.75; max-width: 820px; }
/* ===== 痛点叙事 ===== */
.story {
background: #fff; border: 1px solid #eef2f7; border-radius: 14px;
padding: 34px 38px; box-shadow: 0 1px 3px rgba(15,23,42,.05), 0 8px 26px rgba(15,23,42,.04);
}
.story p { margin: 0 0 15px; font-size: 14.5px; line-height: 1.95; color: #475569; }
.story p:last-child { margin-bottom: 0; }
.story .hl {
display: block; margin: 24px 0; padding: 20px 24px;
background: linear-gradient(100deg, #eff6ff 0%, #f5f3ff 100%);
border-left: 3px solid #3b82f6; border-radius: 8px;
font-size: 16px; font-weight: 650; color: #1e3a8a; line-height: 1.7;
}
.story .risk { color: #b91c1c; font-weight: 600; }
.story .fine { color: #64748b; font-weight: 400; }
/* ===== 能力大卡 ===== */
.cap-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; }
.cap {
background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 26px 28px;
box-shadow: 0 1px 3px rgba(15,23,42,.05), 0 8px 26px rgba(15,23,42,.035);
position: relative; overflow: hidden; transition: transform .2s, box-shadow .2s, border-color .2s;
}
.cap::after {
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
background: linear-gradient(90deg, var(--c1), var(--c2));
opacity: .85;
}
.cap:hover {
transform: translateY(-3px);
box-shadow: 0 6px 14px rgba(15,23,42,.08), 0 18px 40px rgba(15,23,42,.07);
border-color: #dbe3ee;
}
.cap .ic {
width: 44px; height: 44px; border-radius: 12px; margin-bottom: 15px;
display: flex; align-items: center; justify-content: center; font-size: 21px;
background: var(--bg); color: var(--c1);
box-shadow: inset 0 0 0 1px rgba(15,23,42,.045);
}
.cap h3 { margin: 0 0 4px; font-size: 16.5px; font-weight: 700; color: #0f172a; }
.cap .cap-sub { margin: 0 0 12px; font-size: 12.5px; color: #94a3b8; letter-spacing: .2px; }
.cap p { margin: 0 0 14px; font-size: 14px; line-height: 1.85; color: #475569; }
.cap .detail {
margin: 0; padding-top: 13px; border-top: 1px dashed #e2e8f0;
font-size: 12.5px; line-height: 1.8; color: #64748b;
}
.cap .detail b { color: #334155; font-weight: 600; }
.cap .detail code {
background: #f1f5f9; padding: 1px 5px; border-radius: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #b45309;
}
/* ===== 能力矩阵(引擎分层) ===== */
.layers { display: grid; gap: 10px; }
.layer {
display: flex; align-items: flex-start; gap: 16px;
background: #fff; border: 1px solid #eef2f7; border-radius: 11px; padding: 16px 20px;
box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.layer .lv {
flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 12.5px; font-weight: 700; font-family: ui-monospace, Menlo, Consolas, monospace;
background: #f1f5f9; color: #475569;
}
.layer .lt { min-width: 118px; flex-shrink: 0; }
.layer .lt b { display: block; font-size: 14px; color: #1e293b; font-weight: 650; }
.layer .lt span { font-size: 11.5px; color: #94a3b8; }
.layer .ld { font-size: 13.5px; line-height: 1.75; color: #475569; }
.layer .ld b { color: #1e293b; font-weight: 600; }
/* ===== 架构小卡 ===== */
.arch-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.arch {
background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 22px 24px;
box-shadow: 0 1px 3px rgba(15,23,42,.05);
transition: transform .18s, box-shadow .18s;
}
.arch:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(15,23,42,.08); }
.arch .ai { font-size: 20px; margin-bottom: 10px; }
.arch h4 { margin: 0 0 7px; font-size: 14.5px; font-weight: 650; color: #0f172a; }
.arch p { margin: 0; font-size: 12.8px; line-height: 1.8; color: #64748b; }
.arch p b { color: #334155; font-weight: 600; }
/* ===== 底部信息条 ===== */
.foot {
margin-top: 52px; padding: 22px 28px; border-radius: 12px;
background: linear-gradient(100deg, #0f172a 0%, #1e3a5f 100%);
color: rgba(255,255,255,.78); font-size: 13px; line-height: 1.9;
display: flex; flex-wrap: wrap; gap: 10px 40px; align-items: center;
}
.foot .brand { color: #fff; font-weight: 650; font-size: 14px; }
.foot .dot { color: rgba(255,255,255,.32); }
.foot b { color: #7dd3fc; font-weight: 600; }
@media (max-width: 900px) {
.cap-grid, .arch-grid { grid-template-columns: 1fr; }
.hero h1 { font-size: 32px; }
.layer { flex-direction: column; gap: 10px; }
.layer .lt { min-width: 0; }
}
</style>
</head>
<body>
{% set nav_active = 'about' %}
{% include "_nav.html" %}
<div class="hero">
<div class="hero-inner">
<div class="eyebrow">文件摆渡安全卫士</div>
<h1>MeshSafe <span class="grad">· 文件摆渡安全卫士</span></h1>
<p class="lead">
为内外网文件摆渡提供<b>零信任检测</b>能力。每一份传入的文件,在进入内部网络之前
都要经过 6 层引擎的交叉验证 —— 从文件结构完整性到病毒特征、从文档行为到二进制指令,
任何一层发现威胁,整份文件即刻终止通行。
</p>
<div class="tags">
<span>6 层引擎交叉检测</span>
<span>零容忍规则优先响应</span>
<span>真实文件类型识别</span>
<span>全链路审计追溯</span>
<span>完全离线可用</span>
<span>毫秒级响应</span>
</div>
</div>
</div>
<main>
<!-- ===== 用户痛点 ===== -->
<section>
<div class="sec-head">
<div class="kicker">The Challenge</div>
<h2>文件摆渡时,你真正担心的是什么?</h2>
<p>不是"这个文件有没有被杀毒软件报过毒"这么简单。真正让安全负责人睡不着的,往往是下面这些场景。</p>
</div>
<div class="story">
<p>
一份 ISO 镜像刚传入内网,里面藏着的木马就开始在机器上监听端口、拖走数据;
一份看似无害的 Word 文档,员工的电脑双击之后,VBA 宏悄悄把内网信息外发到公网;
一份压缩包里套着另一个压缩包,解压到第 5 层才露出真正的恶意载荷。
</p>
<p>
传统杀毒软件查不到这些 —— 它只认<span class="risk">已被收录的病毒特征</span>。
而真正针对关键基础设施的攻击早已越过这条线:把恶意程序伪装成 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">.txt.exe</code> 用双扩展名蒙混过关;
利用 USB 介质摆渡的 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">.lnk</code> 快捷方式劫持来劫持凭证;
在 PDF 里嵌入 JavaScript 打开远程恶意链接;在 Office 宏里做字符混淆绕过关键字检测。
</p>
<p>
更棘手的是<span class="fine">它不一定会触发任何告警</span>。文件顺利躺在内网服务器的某个目录里,
直到有一天有人双击了它。
</p>
<span class="hl">
MeshSafe 把风险挡在网络边界这一层 —— 让每一个传入的文件,先过一遍安全审判。
</span>
<p>
每一份文件都要依次通过结构完整性检测、病毒特征扫描、文档深度行为分析、
关键词匹配、YARA 规则匹配、PE/二进制分析 —— <b>6 层引擎交叉验证</b>。
命中"零容忍"特征的,<b>直接终止,不打分、不商量</b>。
即使是完全离线的内网环境,病毒特征库也能通过文件摆渡的方式持续更新,保持检测能力不衰减。
</p>
</div>
</section>
<!-- ===== 核心能力 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Core Capabilities</div>
<h2>四个核心能力</h2>
<p>不是把功能点罗列一遍,而是回答四个你在实际运维中真正会问的问题。</p>
</div>
<div class="cap-grid">
<div class="cap" style="--c1:#3b82f6; --c2:#06b6d4; --bg:#eff6ff;">
<div class="ic">🛡️</div>
<h3>6 层引擎交叉检测</h3>
<p class="cap-sub">多层独立引擎 · 单引擎故障不中断整体扫描</p>
<p>
六个引擎各管一摊,横向覆盖不同威胁面。任何一个引擎抛出异常都被安全捕获,
<b>不会因为病毒库没加载或某个解析器出错而让整份扫描失败</b>,更不会因此把文件误放行。
</p>
<p class="detail">
<b>关键设计:</b>引擎异常时任务结论标记为 <code>error</code> 并按<b>拒绝</b>处理 —— 失败即拦截,绝不失败即放行。
</p>
</div>
<div class="cap" style="--c1:#ef4444; --c2:#f97316; --bg:#fef2f2;">
<div class="ic">⚡</div>
<h3>零容忍优先响应</h3>
<p class="cap-sub">20+ 条硬威胁规则 · 命中即拒</p>
<p>
有些特征不需要再算风险分 —— PDF 内嵌可执行 JavaScript、VBA 宏自动执行、
Linux ELF 可执行文件、LNK 快捷方式劫持、ISO 镜像内含可执行文件、路径穿越攻击,
命中任何一条,文件<b>当场终止</b>。
</p>
<p class="detail">
<b>与普通检测的区别:</b>零容忍规则绕过评分机制,不存在"分数高一点就放行"的情况。
</p>
</div>
<div class="cap" style="--c1:#8b5cf6; --c2:#d946ef; --bg:#f5f3ff;">
<div class="ic">🔍</div>
<h3>真实文件类型识别</h3>
<p class="cap-sub">不认扩展名 · 只看文件头魔数</p>
<p>
攻击者最容易钻的空子就是文件名 —— 把 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">payload.exe</code>
改名成 <code style="font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;color:#b45309;">readme.txt</code>。
MeshSafe 直接读取文件头字节判断真实类型,与扩展名不符、双扩展名伪装、
危险可执行扩展名,统统识别出来。
</p>
<p class="detail">
<b>还覆盖:</b>压缩包结构安全预检、嵌套深度限制、解压总量上限(zip bomb 防护),
藏在压缩包里的第二层、第三层载荷同样会被拆开检测。
</p>
</div>
<div class="cap" style="--c1:#10b981; --c2:#14b8a6; --bg:#ecfdf5;">
<div class="ic">📝</div>
<h3>全链路审计追溯</h3>
<p class="cap-sub">哈希链防篡改 · 合规审计可查</p>
<p>
每次扫描的输入文件哈希、判定结论、命中规则明细、时间戳、来源渠道全部落库,
形成完整审计链。审计记录采用<b>哈希链</b>结构串联,任何一条被事后修改,
后续链路的校验立刻失效。
</p>
<p class="detail">
<b>合规价值:</b>等保测评、审计问询时"某个文件当时为什么被拦截",
可以直接调出完整判定过程,而不是靠回忆。
</p>
</div>
</div>
</section>
<!-- ===== 引擎分层 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Detection Pipeline</div>
<h2>6 层检测管线</h2>
<p>每一层解决一类问题,层层递进不重复。</p>
</div>
<div class="layers">
<div class="layer">
<div class="lv">L0</div>
<div class="lt"><b>结构与完整性</b><span>Structure</span></div>
<div class="ld">识别真实文件类型、检出扩展名伪装与双扩展名、判定危险可执行扩展名、对压缩包做结构安全预检,拦截路径穿越与 zip bomb。</div>
</div>
<div class="layer">
<div class="lv">L1</div>
<div class="lt"><b>病毒特征</b><span>ClamAV</span></div>
<div class="ld">对接 ClamAV 病毒引擎做特征匹配,命中已知恶意样本直接判定恶意。病毒库可离线更新,完全不依赖外网。</div>
</div>
<div class="layer">
<div class="lv">L2</div>
<div class="lt"><b>文档深度分析</b><span>Document</span></div>
<div class="ld">解析 Office 宏、PDF 动作、OLE 嵌入对象、邮件附件等结构化内容,判断文档是否包含自动执行或外联行为。</div>
</div>
<div class="layer">
<div class="lv">L3</div>
<div class="lt"><b>关键词匹配</b><span>Keywords</span></div>
<div class="ld">在文本与脚本内容中匹配反弹 Shell、远程下载执行、凭据外传等高危行为模式,识别"无病毒但有恶意意图"的内容。</div>
</div>
<div class="layer">
<div class="lv">L4</div>
<div class="lt"><b>YARA 规则</b><span>YARA</span></div>
<div class="ld">加载自定义规则集匹配已知家族与自定义特征,规则可由安全团队随业务需要持续扩充,无需改代码。</div>
</div>
<div class="layer">
<div class="lv">L5</div>
<div class="lt"><b>PE/二进制分析</b><span>Binary</span></div>
<div class="ld">对可执行文件做指令级特征分析,识别加壳、混淆、可疑导入组合等静态分析可疑点,覆盖无签名但行为异常的程序。</div>
</div>
</div>
</section>
<!-- ===== 架构 ===== -->
<section>
<div class="sec-head">
<div class="kicker">Security Architecture</div>
<h2>为生产环境而生的架构</h2>
<p>安全能力不是叠加出来的,是从接入层到存储层贯穿设计的。</p>
</div>
<div class="arch-grid">
<div class="arch">
<div class="ai">🔐</div>
<h4>签名认证接入</h4>
<p>
每个调用方分配独立 <b>client_id + client_secret</b>,请求必须携带
<b>HMAC-SHA256 签名</b>。服务端用 HKDF 派生请求/响应双向独立密钥,
时间戳窗口 + 一次性 nonce 双重防重放,伪造请求进不来。
</p>
</div>
<div class="arch">
<div class="ai">🚦</div>
<h4>分层限流保护</h4>
<p>
扫描接口与非扫描接口<b>分别限流</b>,按调用方维度在 Redis 中滑动窗口计数,
多实例部署下计数依然准确。超限返回 429 与重试提示,不拖垮后端。
</p>
</div>
<div class="arch">
<div class="ai">🏗️</div>
<h4>高可用部署</h4>
<p>
多 worker 进程 + 数据库连接池 + 常驻病毒扫描守护进程,
并发扫描不阻塞事件循环。相同文件重复提交直接命中缓存秒级返回,不重复消耗算力。
</p>
</div>
</div>
</section>
<!-- ===== 底部信息 ===== -->
<section>
<div class="foot">
<span class="brand">MeshSafe</span>
<span>文件摆渡安全卫士</span>
<span class="dot">|</span>
<span>版本 <b>v1.0</b></span>
<span class="dot">|</span>
<span>完全离线部署可用</span>
<span class="dot">|</span>
<span>扫描结论与响应全程可验签</span>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1320px; margin: 0 auto; }
/* ===== 顶部标题栏 ===== */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20px; }
.page-head h1 { margin: 0 0 6px; font-size: 21px; font-weight: 700; letter-spacing: .3px; }
.page-head .sub { font-size: 13px; color: #64748b; }
.policy-chip {
display: inline-flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 14px; font-size: 12.5px; color: #475569;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.policy-chip b { color: #0f172a; }
.policy-chip code { color: #64748b; font-size: 11.5px; }
/* ===== 统计卡片行 ===== */
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 22px; }
.stat {
background: #fff; border-radius: 12px; padding: 18px 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; position: relative; overflow: hidden;
transition: transform .18s, box-shadow .18s;
}
.stat:hover { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(15,23,42,.09), 0 12px 30px rgba(15,23,42,.07); }
.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
.stat.s1::before { background: #3b82f6; } .stat.s2::before { background: #ef4444; }
.stat.s3::before { background: #f59e0b; } .stat.s4::before { background: #8b5cf6; }
.stat.s5::before { background: #10b981; }
.stat .s-label { font-size: 12.5px; color: #64748b; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.stat .s-value { font-size: 28px; font-weight: 700; color: #0f172a; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .s-value .unit { font-size: 14px; font-weight: 500; color: #94a3b8; margin-left: 3px; }
.stat .s-foot { font-size: 11.5px; color: #94a3b8; margin-top: 7px; }
/* ===== 主内容两栏 ===== */
.cols { display: grid; grid-template-columns: 1fr 320px; gap: 18px; align-items: start; }
.panel {
background: #fff; border-radius: 12px; padding: 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; margin-bottom: 18px;
}
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: #0f172a; display: flex; align-items: center; gap: 8px; }
.panel-head .hint { font-size: 12px; color: #94a3b8; }
/* ===== 引擎卡片 ===== */
.engine-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.ecard {
border: 1px solid #e8edf4; border-radius: 10px; padding: 14px;
background: #fcfdff; transition: all .18s;
}
.ecard:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99,102,241,.1); }
.ecard .e-top { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; }
.ecard .e-icon {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 15px;
background: #eff6ff; color: #3b82f6;
}
.ecard .e-name { font-size: 13.5px; font-weight: 600; color: #1e293b; line-height: 1.25; }
.ecard .e-ver { font-size: 11px; color: #94a3b8; font-family: monospace; }
.badge {
margin-left: auto; font-size: 11px; padding: 3px 9px; border-radius: 20px;
font-weight: 500; white-space: nowrap; flex-shrink: 0;
}
.badge.ok { background: #dcfce7; color: #15803d; }
.badge.error { background: #fee2e2; color: #b91c1c; }
.badge.degraded { background: #fef3c7; color: #b45309; }
.ecard .e-meta { font-size: 11.5px; color: #64748b; line-height: 1.7; }
.ecard .e-meta .k { color: #94a3b8; }
.ecard .e-meta .v { color: #334155; font-weight: 500; }
.ecard .e-err { margin-top: 8px; font-size: 11.5px; color: #b91c1c; background: #fef2f2;
padding: 6px 9px; border-radius: 6px; line-height: 1.5; }
.clamav-actions { margin-top: 11px; display: flex; gap: 7px; }
.clamav-actions button {
flex: 1; padding: 6px 8px; border: none; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 500; transition: all .18s;
}
.btn-online { background: #e0f2fe; color: #0369a1; }
.btn-online:hover { background: #bae6fd; }
.btn-upload { background: #dcfce7; color: #15803d; }
.btn-upload:hover { background: #bbf7d0; }
/* ===== 结论分布条 ===== */
.dist-bar { display: flex; height: 10px; border-radius: 6px; overflow: hidden; background: #f1f5f9; margin-bottom: 12px; }
.dist-bar span { display: block; height: 100%; }
.dist-bar .d-ok { background: #10b981; }
.dist-bar .d-warn { background: #f59e0b; }
.dist-bar .d-bad { background: #ef4444; }
.dist-bar .d-err { background: #8b5cf6; }
.dist-bar .d-muted { background: #94a3b8; }
.dist-legend { display: flex; flex-wrap: wrap; gap: 14px; }
.dist-legend .li { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #64748b; }
.dist-legend .li .dot { width: 8px; height: 8px; border-radius: 3px; }
.dist-legend .li b { color: #1e293b; font-variant-numeric: tabular-nums; }
.dist-legend .li .pct { color: #94a3b8; font-size: 11px; }
/* ===== 渠道排行 ===== */
.rank-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px dashed #eef2f7; }
.rank-item:last-child { border-bottom: none; }
.rank-item .r-no {
width: 20px; height: 20px; border-radius: 5px; background: #eff6ff; color: #3b82f6;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rank-item .r-name { font-size: 12.5px; color: #1e293b; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-item .r-name small { display: block; font-family: -apple-system, "PingFang SC", sans-serif; color: #94a3b8; font-size: 11px; }
.rank-item .r-count { margin-left: auto; font-size: 13px; font-weight: 600; color: #334155; font-variant-numeric: tabular-nums; }
/* ===== 任务表 ===== */
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 9px 12px; text-align: left; font-size: 11.5px;
color: #64748b; font-weight: 600; border-bottom: 1px solid #e8edf4; white-space: nowrap; }
td { padding: 10px 12px; font-size: 12.5px; border-bottom: 1px solid #f4f7fb; color: #334155; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #fafbfd; }
td.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #64748b; }
td.fname { font-weight: 500; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: #3b82f6; text-decoration: none; font-size: 12px; }
.link:hover { text-decoration: underline; }
.empty { text-align: center; color: #94a3b8; padding: 32px 0; font-size: 13px; }
/* ===== 模态框 ===== */
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(15,23,42,.5);
z-index: 100; align-items: center; justify-content: center; }
.modal-mask.show { display: flex; }
.modal { background: white; border-radius: 14px; width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
.modal-header { background: linear-gradient(135deg, #0f172a, #1e3a5f); color: white; padding: 15px 20px;
font-size: 15px; font-weight: 500; display: flex; justify-content: space-between; align-items: center; }
.modal-header .close { cursor: pointer; opacity: .7; font-size: 22px; line-height: 1; }
.modal-header .close:hover { opacity: 1; }
.modal-body { padding: 20px; font-size: 13px; }
.modal-body p { margin: 8px 0; color: #555; }
.modal-body .hint { background: #fffbeb; border-left: 3px solid #f59e0b; padding: 11px 14px;
border-radius: 0 6px 6px 0; margin: 13px 0; font-size: 12px; color: #78350f; line-height: 1.7; }
.modal-body label { display: block; margin: 13px 0 6px; font-weight: 500; color: #333; }
.modal-body input[type="file"] { width: 100%; padding: 9px; border: 1.5px dashed #cbd5e1; border-radius: 8px; background: #f8fafc; }
.modal-body .result-box { margin-top: 13px; padding: 11px 14px; border-radius: 8px; font-size: 12px; display: none; white-space: pre-wrap; line-height: 1.7; }
.modal-body .result-box.show { display: block; }
.result-box.ok { background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0; }
.result-box.err { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.modal-footer { padding: 14px 20px; border-top: 1px solid #eef2f7; display: flex; justify-content: flex-end; gap: 8px; }
.modal-footer button { padding: 8px 20px; border: none; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 500; }
.btn-primary { background: #1e3a5f; color: white; }
.btn-primary:hover { background: #152060; }
.btn-primary:disabled { background: #cbd5e1; cursor: not-allowed; }
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; }
@media (max-width: 1150px) {
.stat-row { grid-template-columns: repeat(3, 1fr); }
.cols { grid-template-columns: 1fr; }
.engine-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.stat-row { grid-template-columns: repeat(2, 1fr); }
.engine-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
{% set nav_active = 'overview' %}{% include "_nav.html" %}
<main>
<!-- ===== 顶部标题 + 策略 ===== -->
<div class="page-head">
<div>
<h1>系统概览</h1>
<div class="sub">光闸摆渡文件安全检测 · 实时运行状态与统计</div>
</div>
<div class="policy-chip">
当前策略 <b>{{ policy_store.current().version }}</b>
<span>·</span>
<code>{{ policy_store.current().sha256[:16] }}</code>
</div>
</div>
<!-- ===== 统计卡片 ===== -->
<div class="stat-row">
<div class="stat s1">
<div class="s-label">📊 累计扫描</div>
<div class="s-value">{{ stats.total_scans }}<span class="unit">个文件</span></div>
<div class="s-foot">检测引擎处理总量</div>
</div>
<div class="stat s2">
<div class="s-label">🛑 拦截次数</div>
<div class="s-value">{{ stats.total_blocked }}<span class="unit">次</span></div>
<div class="s-foot">零容忍命中直接阻断</div>
</div>
<div class="stat s3">
<div class="s-label">⚠️ 检出问题</div>
<div class="s-value">{{ stats.total_findings }}<span class="unit">项</span></div>
<div class="s-foot">规则命中 findings 总数</div>
</div>
<div class="stat s4">
<div class="s-label">📋 规则总数</div>
<div class="s-value">{{ stats.total_rules }}<span class="unit">条</span></div>
<div class="s-foot">零容忍 {{ stats.zero_tolerance }} · YARA {{ stats.yara_rules }}</div>
</div>
<div class="stat s5">
<div class="s-label">⚡ 引擎在线</div>
<div class="s-value">{{ stats.engines_ok }}<span class="unit">/ {{ stats.engines_total }}</span></div>
<div class="s-foot">全部引擎可用则正常检测</div>
</div>
</div>
<!-- ===== 两栏主区 ===== -->
<div class="cols">
<div>
<!-- ===== 引擎状态 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🧩 引擎状态</h2>
<span class="hint">{{ stats.engines_ok }} / {{ stats.engines_total }} 引擎在线</span>
</div>
<div class="engine-grid">
{% for h in engine_health %}
<div class="ecard">
<div class="e-top">
<div class="e-icon">{% if h.engine == 'structure' %}🗂️{% elif h.engine == 'clamav' %}🦠{% elif h.engine == 'document' %}📄{% elif h.engine == 'keywords' %}🔑{% elif h.engine == 'yara' %}🎯{% elif h.engine == 'binary' %}⚙️{% else %}🔧{% endif %}</div>
<div>
<div class="e-name">{{ h.display_name }}</div>
{% if h.engine_version_label %}<div class="e-ver">{{ h.engine_version_label }}</div>{% endif %}
</div>
<span class="badge {{ 'ok' if h.ok else ('error' if h.error else 'degraded') }}">{{ h.status_label or h.status }}</span>
</div>
<div class="e-meta">
{% if h.engine == 'clamav' %}
<div><span class="k">病毒库</span> <span class="v" id="clamav-sig-date">加载中...</span></div>
{% elif h.engine == 'yara' %}
<div><span class="k">规则库</span> <span class="v">{{ stats.yara_rules }} 条规则已加载</span></div>
{% elif h.engine == 'keywords' %}
<div><span class="k">特征词表</span> <span class="v">{{ policy_store.current().keyword_rules|length }} 条规则</span></div>
{% else %}
<div><span class="k">层级</span> <span class="v">{{ h.layer_label }}</span></div>
{% endif %}
</div>
{% if h.friendly_error %}
<div class="e-err">{{ h.friendly_error }}</div>
{% endif %}
{% if h.engine == 'clamav' %}
<div class="clamav-actions">
<button class="btn-online" onclick="clamavUpdate(this)">🔄 在线更新</button>
<button class="btn-upload" onclick="openUploadModal()">📤 上传签名库</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- ===== 检测结论分布 ===== -->
<div class="panel">
<div class="panel-head">
<h2>📈 检测结论分布</h2>
<span class="hint">共 {{ stats.total_scans }} 次扫描</span>
</div>
{% if verdict_items %}
<div class="dist-bar">
{% for v in verdict_items %}
<span class="d-{{ v.cls }}" style="width: {{ v.pct }}%" title="{{ v.label }} {{ v.pct }}%"></span>
{% endfor %}
</div>
<div class="dist-legend">
{% for v in verdict_items %}
<div class="li">
<span class="dot d-{{ v.cls }}"></span>
<span>{{ v.label }}</span>
<b>{{ v.count }}</b>
<span class="pct">{{ v.pct }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">暂无扫描数据</div>
{% endif %}
</div>
<!-- ===== 最近扫描任务 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🕐 最近扫描任务</h2>
<a class="link" href="/console/tasks">查看全部 →</a>
</div>
{% if recent_tasks %}
<table>
<thead>
<tr>
<th>时间</th><th>文件名</th><th>来源渠道</th><th>大小</th><th>结论</th><th>动作</th><th>分数</th><th></th>
</tr>
</thead>
<tbody>
{% for t in recent_tasks %}
<tr>
<td class="mono">{{ t.created_at|bj_clock if t.created_at else '' }}</td>
<td class="fname" title="{{ t.filename }}">{{ t.filename }}</td>
<td class="mono" title="{{ t.client_id }} / {{ t.channel }}">{{ t.channel|cn_channel or '-' }}</td>
<td class="mono">{{ (t.size / 1024)|round(1) if t.size else 0 }} KB</td>
<td><span class="tag {{ t.verdict|cn_verdict_badge }}" title="{{ t.verdict }}">{{ t.verdict|cn_verdict }}</span></td>
<td><span class="tag plain" title="{{ t.action }}">{{ t.action|cn_action }}</span></td>
<td class="mono">{{ t.score }}</td>
<td><a class="link" href="/console/tasks#task-{{ t.id }}">详情</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">暂无扫描记录,先去 <a class="link" href="/console/test">测试靶场</a> 跑一批样本吧</div>
{% endif %}
</div>
</div>
<!-- ===== 右栏 ===== -->
<div>
<!-- 策略要点 -->
<div class="panel">
<div class="panel-head"><h2>🛡️ 策略要点</h2></div>
<div class="e-meta" style="font-size:12.5px; line-height:2;">
<div><span class="k">零容忍规则</span> <span class="v">{{ stats.zero_tolerance }} 条</span></div>
<div><span class="k">YARA 规则</span> <span class="v">{{ stats.yara_rules }} 条</span></div>
<div><span class="k">规则覆盖</span> <span class="v">{{ policy_store.current().rule_overrides|length }} 条</span></div>
<div><span class="k">拦截阈值</span> <span class="v">≥ {{ policy_store.current().block_score }} 分</span></div>
<div><span class="k">文件大小上限</span> <span class="v">{{ (policy_store.current().max_file_size / 1024 / 1024 / 1024)|round(2) }} GB</span></div>
</div>
</div>
<!-- 渠道排行 -->
<div class="panel">
<div class="panel-head">
<h2>🔗 摆渡来源 TOP</h2>
<span class="hint">按任务数</span>
</div>
{% if channel_rank %}
{% for c in channel_rank %}
<div class="rank-item">
<div class="r-no">{{ loop.index }}</div>
<div class="r-name">{{ c.channel|cn_channel }}<small>{{ c.client_id }}</small></div>
<div class="r-count">{{ c.count }}</div>
</div>
{% endfor %}
{% else %}
<div class="empty">暂无渠道数据</div>
{% endif %}
</div>
</div>
</div>
</main>
<!-- ========== 上传签名库模态框 ========== -->
<div class="modal-mask" id="uploadModal">
<div class="modal">
<div class="modal-header">
📤 离线导入 ClamAV 签名库
<span class="close" onclick="closeUploadModal()">×</span>
</div>
<div class="modal-body">
<p>选择需要更新的 ClamAV 签名库文件(可多选):</p>
<div class="hint">
💡 光闸内网无法联网,请先在<strong>外网电脑</strong>下载最新签名库,通过 U 盘/摆渡带进内网后上传。<br>
下载地址:<code>https://database.clamav.net/main.cvd</code> / <code>daily.cvd</code> / <code>bytecode.cvd</code><br>
clamd 会自动热加载新签名,无需重启。
</div>
<label>选择文件(.cvd / .cld):</label>
<input type="file" id="uploadFiles" multiple accept=".cvd,.cld,.ign2">
<div class="result-box" id="uploadResult"></div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeUploadModal()">取消</button>
<button class="btn-primary" id="uploadSubmitBtn" onclick="submitUpload()">确认上传</button>
</div>
</div>
</div>
<script>
// ========== ClamAV 病毒库信息加载 ==========
async function loadClamavInfo() {
const el = document.getElementById('clamav-sig-date');
if (!el) return;
try {
const resp = await fetch('/console/clamav/info', { credentials: 'same-origin' });
const data = await resp.json();
if (!data.daemon_ok) {
el.textContent = '守护进程不可用';
el.style.color = '#b91c1c';
return;
}
// 只显示到日期,不显示时分秒
const d = (data.signature_date || '').slice(0, 10);
el.textContent = d || '未知';
el.style.color = '';
if (data.signature_days_old !== null && data.signature_days_old > 7) {
el.textContent = d + '(已 ' + data.signature_days_old + ' 天)';
el.style.color = '#b45309';
}
} catch (e) {
el.textContent = '加载失败';
el.style.color = '#b91c1c';
}
}
// ========== 在线更新 ==========
async function clamavUpdate(btn) {
if (!confirm('确认尝试在线更新病毒库?\n光闸内网环境大概率拉不到(断网隔离)。')) return;
const old = btn.textContent;
btn.disabled = true; btn.textContent = '更新中...';
try {
const resp = await fetch('/console/clamav/update', { method: 'POST', credentials: 'same-origin' });
const data = await resp.json();
if (data.ok) {
alert('✅ ' + data.message + '\n\n' + (data.output || ''));
} else {
alert('⚠️ ' + data.message + '\n\n请改用「上传签名库」离线导入。');
}
loadClamavInfo();
} catch (e) {
alert('❌ 请求失败: ' + e);
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ========== 上传模态框 ==========
function openUploadModal() {
document.getElementById('uploadModal').classList.add('show');
document.getElementById('uploadResult').className = 'result-box';
document.getElementById('uploadResult').textContent = '';
document.getElementById('uploadFiles').value = '';
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('show');
}
async function submitUpload() {
const input = document.getElementById('uploadFiles');
const result = document.getElementById('uploadResult');
const btn = document.getElementById('uploadSubmitBtn');
if (!input.files || input.files.length === 0) {
result.className = 'result-box err show';
result.textContent = '❌ 请先选择要上传的文件';
return;
}
const fd = new FormData();
for (let i = 0; i < input.files.length; i++) {
fd.append('files', input.files[i]);
}
btn.disabled = true; btn.textContent = '上传中...';
result.className = 'result-box';
result.textContent = '';
try {
const resp = await fetch('/console/clamav/upload', {
method: 'POST', body: fd, credentials: 'same-origin',
});
const data = await resp.json();
result.className = 'result-box ' + (data.ok ? 'ok' : 'err') + ' show';
let txt = '';
if (data.saved && data.saved.length > 0) {
txt += '✅ 成功导入 ' + data.saved.length + ' 个文件:\n';
data.saved.forEach(s => txt += ' · ' + s.name + ' (' + s.size_mb + ' MB)\n');
txt += '\n' + (data.reload_message || '');
}
if (data.errors && data.errors.length > 0) {
txt += '\n❌ 失败:\n';
data.errors.forEach(e => txt += ' · ' + e + '\n');
}
if (data.skipped && data.skipped.length > 0) {
txt += '\n⏭️ 跳过:\n';
data.skipped.forEach(s => txt += ' · ' + s + '\n');
}
result.textContent = txt || data.message || '完成';
loadClamavInfo();
} catch (e) {
result.className = 'result-box err show';
result.textContent = '❌ 请求失败: ' + e;
} finally {
btn.disabled = false; btn.textContent = '确认上传';
}
}
loadClamavInfo();
</script>
</body>
</html>

View File

@@ -0,0 +1,415 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe · 诊断面板</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
/* 诊断面板专用样式 */
.diag-wrap { padding: 28px 40px; max-width: 1180px; margin: 0 auto; }
.diag-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 24px;
}
.diag-header h1 { font-size: 20px; font-weight: 700; color: #0f172a; }
.diag-header .meta { font-size: 12.5px; color: #64748b; }
/* 统计卡 */
.diag-stats {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px;
margin-bottom: 24px;
}
.diag-stat {
background: white; border: 1px solid #e2e8f0; border-radius: 12px;
padding: 18px 20px;
}
.diag-stat .label { font-size: 12px; color: #64748b; margin-bottom: 4px; }
.diag-stat .val { font-size: 26px; font-weight: 700; color: #0f172a; }
.diag-stat .val.crit { color: #b91c1c; }
.diag-stat .val.warn { color: #b45309; }
.diag-stat .val.ok { color: #15803d; }
/* 区块 */
.diag-block {
background: white; border: 1px solid #e2e8f0; border-radius: 12px;
margin-bottom: 20px;
}
.diag-block h2 {
font-size: 14px; font-weight: 600; color: #0f172a;
padding: 14px 20px; border-bottom: 1px solid #f1f5f9;
}
/* 规则列表 */
.rule-item {
padding: 12px 20px; border-bottom: 1px solid #f8fafc;
display: flex; align-items: flex-start; gap: 12px;
}
.rule-item:last-child { border-bottom: none; }
.rule-item .rule-id {
font-family: monospace; font-size: 12px; color: #94a3b8;
min-width: 44px; flex-shrink: 0;
}
.rule-item .rule-body { flex: 1; min-width: 0; }
.rule-item .rule-msg {
font-size: 13.5px; color: #0f172a; margin-bottom: 2px;
}
.rule-item .rule-hint {
font-size: 12px; color: #64748b; line-height: 1.5;
white-space: pre-line;
}
.rule-item .rule-evidence {
font-size: 11.5px; color: #94a3b8; font-family: monospace;
margin-top: 4px; padding: 6px 10px; background: #f8fafc; border-radius: 6px;
max-height: 120px; overflow-y: auto; white-space: pre-wrap;
}
.rule-item .rule-time {
font-size: 11px; color: #94a3b8; flex-shrink: 0;
}
.rule-item.skipped .rule-msg { color: #94a3b8; }
/* 容器/引擎表 */
.diag-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.diag-table th {
text-align: left; padding: 10px 20px; font-weight: 500;
color: #64748b; font-size: 12px; border-bottom: 1px solid #f1f5f9;
}
.diag-table td { padding: 10px 20px; border-bottom: 1px solid #f8fafc; color: #0f172a; }
.diag-table tr:last-child td { border-bottom: none; }
.engine-name, .container-name { font-weight: 500; }
.status-running { color: #15803d; font-weight: 500; }
.status-exited { color: #b91c1c; font-weight: 500; }
.status-paused { color: #b45309; font-weight: 500; }
/* 未启用横幅 */
.diag-disabled {
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border: 1px solid #cbd5e1; border-radius: 12px;
padding: 28px 32px; text-align: center; margin-bottom: 24px;
}
.diag-disabled h2 { font-size: 18px; font-weight: 600; color: #475569; margin-bottom: 10px; }
.diag-disabled p { font-size: 13px; color: #64748b; line-height: 1.7; }
/* 空状态 */
.diag-empty {
padding: 20px; text-align: center; color: #94a3b8; font-size: 13px;
}
/* 按钮 */
.btn-export {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 22px;
background: linear-gradient(135deg, #1e3a5f, #3b82f6);
color: white; border: none; border-radius: 8px;
font-size: 13.5px; font-weight: 500; cursor: pointer;
text-decoration: none; transition: all .2s;
}
.btn-export:hover { box-shadow: 0 4px 12px rgba(59,130,246,.3); transform: translateY(-1px); }
.diag-footer {
display: flex; justify-content: flex-end; align-items: center;
gap: 16px; margin-top: 20px;
}
/* 刷新按钮 */
.refresh-btn {
display: inline-flex; align-items: center; gap: 4px;
background: transparent; border: none; cursor: pointer;
color: #94a3b8; font-size: 12px; font-weight: 400;
margin-left: 8px; padding: 2px 6px; border-radius: 4px;
transition: color .2s, background .2s;
vertical-align: middle;
}
.refresh-btn:hover { color: #3b82f6; background: rgba(59,130,246,.08); }
.refresh-btn svg { transition: transform .6s cubic-bezier(.4,0,.2,1); }
.refresh-btn.spinning svg { animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.refresh-time { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
{% include "_nav.html" %}
<div class="diag-wrap">
<!-- 顶部 -->
<div class="diag-header">
<h1>🔧 系统诊断</h1>
<div class="meta">
🕐 刷新时间: {{ refresh_time }}
&nbsp;·&nbsp; {{ docker_status_msg }}
{% if started_at %}&nbsp;·&nbsp; 诊断启用: {{ started_at[:19].replace('T', ' ') }}{% endif %}
</div>
</div>
<!-- 未启用横幅 -->
{% if not diag_enabled %}
<div class="diag-disabled">
<h2>⛔ 诊断套件未启用</h2>
<p>diagnostics.yaml: diagnostics.enabled = false<br>
启用后将开启后台定时检测(三个定时器,CPU 占用可忽略)。<br>
当前仅保留手动导出功能,自动规则引擎已停止。</p>
<p style="margin-top:14px;"><a href="/api/v1/telemetry/dump" class="btn-export">📦 手动导出诊断包</a></p>
</div>
{% else %}
<!-- 统计卡 -->
<div class="diag-stats">
<div class="diag-stat">
<div class="label">规则总数</div>
<div class="val">{{ rule_count }}</div>
</div>
<div class="diag-stat">
<div class="label">严重</div>
<div class="val crit">{{ crit_count }}</div>
</div>
<div class="diag-stat">
<div class="label">警告</div>
<div class="val warn">{{ warn_count }}</div>
</div>
<div class="diag-stat">
<div class="label">通过</div>
<div class="val ok">{{ ok_count }}</div>
</div>
</div>
<!-- 异常检测 -->
<div class="diag-block">
<h2>⚠️ 规则检测</h2>
{% if all_rules %}
{% for r in all_rules %}
<div class="rule-item {% if r.skipped %}skipped{% endif %}">
<span class="rule-id">{{ r.id }}</span>
<div class="rule-body">
<div class="rule-msg">
<span class="{{ badge_for(r.severity) }}">{{ {
'high': '严重', 'medium': '警告', 'low': '提示',
'info': '信息', 'ok': '通过'
}.get(r.severity, '未知') }}</span>
&nbsp;{{ r.message }}
</div>
{% if r.hint %}<div class="rule-hint">💡 {{ r.hint }}</div>{% endif %}
{% if r.evidence %}
<div class="rule-evidence">{% for e in r.evidence %}{{ e }}{% if not loop.last %}\n{% endif %}{% endfor %}</div>
{% endif %}
{% if r.skipped and r.skip_reason %}
<div class="rule-hint" style="color:#94a3b8;">⏭️ 跳过原因: {{ r.skip_reason }}</div>
{% endif %}
</div>
<span class="rule-time">
{% if r.run_at %}{{ r.run_at }}{% endif %}
{% if r.run_by %}<br><span style="color:#cbd5e1;font-size:10.5px;">{{ r.run_by }}</span>{% endif %}
</span>
</div>
{% endfor %}
{% else %}
<div class="diag-empty">暂无规则结果(定时器还没跑起来?)</div>
{% endif %}
</div>
<!-- 引擎健康 -->
<div class="diag-block" id="engine-health-wrap">
<h2>🧪 引擎状态
<button id="engine-refresh-btn" class="refresh-btn" title="立即刷新" aria-label="刷新">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
<span id="engine-refresh-time" class="refresh-time">—</span>
</button>
</h2>
<div id="engine-health-body">
{% if engine_health %}
<table class="diag-table">
<thead><tr><th>引擎</th><th>状态</th><th>版本 / 详情</th></tr></thead>
<tbody>
{% for name, info in engine_health.items() %}
<tr>
<td class="engine-name">{{ name }}</td>
<td>
{% if info.get('ok') or info.get('available') %}
<span class="tag ok">在线</span>
{% else %}
<span class="tag bad">离线</span>
{% endif %}
</td>
<td style="color:#64748b;font-size:12px;">
{% if info.get('version') %}版本: {{ info.version }}{% endif %}
{% if info.get('signature_date') %}· 病毒库: {{ info.signature_date }}{% endif %}
{% if info.get('rules_loaded') is not none %}· 规则: {{ info.rules_loaded }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="diag-empty">引擎健康信息不可用</div>
{% endif %}
</div>
</div>
<!-- 容器状态(Docker 可用时显示)-->
{% if docker_status == 'full' %}
<div class="diag-block">
<h2>🐳 容器运行状态</h2>
{% if docker_containers %}
<table class="diag-table">
<thead><tr><th>容器</th><th>状态</th><th>健康</th><th>Docker重启</th><th>历史重启</th><th>运行时长</th></tr></thead>
<tbody>
{% for c in docker_containers %}
{% if 'meshsafe' in c.name %}
<tr>
<td class="container-name">{{ c.name }}</td>
<td class="status-{{ c.status }}">
{% if c.oom_killed %}<span class="tag bad">OOM</span> {% endif %}
{{ c.status }}
</td>
<td>
{% if c.health %}
{% if c.health == 'healthy' %}<span class="tag ok">✓ healthy</span>
{% elif c.health == 'unhealthy' %}<span class="tag bad">✗ unhealthy</span>
{% elif c.health == 'starting' %}<span class="tag warn">… starting</span>
{% else %}{{ c.health }}{% endif %}
{% else %}—{% endif %}
</td>
<td>
{% if c.restart_count and c.restart_count > 3 %}
<span class="tag bad">{{ c.restart_count }}</span>
{% else %}
{{ c.restart_count if c.restart_count is not none else '-' }}
{% endif %}
</td>
<td>
{% if c.historical_restart_count and c.historical_restart_count > 0 %}
<span class="tag warn">{{ c.historical_restart_count }}</span>
{% else %}
0
{% endif %}
</td>
<td>{{ c.uptime or '—' }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
<div class="diag-empty" style="font-size:11.5px;color:#94a3b8;margin-top:6px;padding:0;">
说明:Docker重启 = RestartCount(仅自动崩溃被拉起计数);
历史重启 = R015 检测到的 StartedAt/restart_count 变化(含手动 docker restart)
</div>
{% else %}
<div class="diag-empty">未发现 meshsafe 容器</div>
{% endif %}
</div>
{% elif docker_status == 'partial' %}
<div class="diag-block">
<h2>🐳 容器运行状态 <span class="tag warn">Docker 访问受限</span></h2>
<div class="diag-empty">部分容器信息不可用 — {{ docker_info.get('message', '') }}</div>
</div>
{% elif docker_status == 'none' %}
<div class="diag-block">
<h2>🐳 容器运行状态 <span class="tag muted">未启用</span></h2>
<div class="diag-empty">
Docker 日志检测未启用 — 如需启用,在 docker-compose.yml 的 api 服务中加<br>
<code>volumes: /var/run/docker.sock:/var/run/docker.sock:ro</code><br>
并确保 <code>group_add: ["999"]</code>
</div>
</div>
{% endif %}
<!-- 底部导出 -->
<div class="diag-footer">
<a href="/api/v1/telemetry/dump" class="btn-export" onclick="return confirm('将导出最近 500 条扫描记录、引擎状态、容器日志。\n不含客户内部文件名、IP、用户名等敏感信息。\n确定导出?')">📦 一键导出诊断包</a>
</div>
{% endif %} <!-- diag_enabled endif -->
</div>
<script>
(function(){
var REFRESH_INTERVAL = 10000; // 10 秒
var btn = document.getElementById('engine-refresh-btn');
var timeEl = document.getElementById('engine-refresh-time');
var bodyEl = document.getElementById('engine-health-body');
if (!btn || !bodyEl) return;
var timer = null;
function fmtTime(d){
var p = n => n.toString().padStart(2,'0');
return p(d.getHours())+':'+p(d.getMinutes())+':'+p(d.getSeconds());
}
function buildHealthHTML(health){
if (!health || Object.keys(health).length === 0){
return '<div class="diag-empty">引擎健康信息不可用</div>';
}
var rows = Object.entries(health).map(function([name, info]){
var online = info.ok || info.available;
var tag = online
? '<span class="tag ok">在线</span>'
: '<span class="tag bad">离线</span>';
var parts = [];
if (info.version) parts.push('版本: ' + info.version);
if (info.signature_date) parts.push('· 病毒库: ' + info.signature_date);
if (info.rules_loaded != null) parts.push('· 规则: ' + info.rules_loaded);
var detail = parts.length
? '<td style="color:#64748b;font-size:12px;">' + parts.join('') + '</td>'
: '<td style="color:#64748b;font-size:12px;">—</td>';
return '<tr>' +
'<td class="engine-name">' + name + '</td>' +
'<td>' + tag + '</td>' +
detail +
'</tr>';
}).join('');
return '<table class="diag-table">' +
'<thead><tr><th>引擎</th><th>状态</th><th>版本 / 详情</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
function refresh(){
btn.classList.add('spinning');
fetch('/api/v1/telemetry/summary', {credentials: 'same-origin'})
.then(function(r){ return r.json(); })
.then(function(data){
if (data.engine_health){
bodyEl.innerHTML = buildHealthHTML(data.engine_health);
}
if (timeEl) timeEl.textContent = fmtTime(new Date());
})
.catch(function(){
if (timeEl) timeEl.textContent = '失败';
})
.finally(function(){
btn.classList.remove('spinning');
});
}
btn.addEventListener('click', function(e){
e.preventDefault();
refresh();
});
// 10 秒自动刷新
timer = setInterval(refresh, REFRESH_INTERVAL);
// 页面隐藏时暂停(省请求)
document.addEventListener('visibilitychange', function(){
if (document.hidden){
if (timer){ clearInterval(timer); timer = null; }
} else {
if (!timer){ timer = setInterval(refresh, REFRESH_INTERVAL); refresh(); }
}
});
// 首次记录时间
if (timeEl) timeEl.textContent = fmtTime(new Date());
})();
</script>
</body>
</html>

278
app/templates/login.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe · 文件摆渡安全卫士</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
min-height: 100vh;
display: flex;
background: #f8fafc;
}
/* ========= 左栏:品牌区 ========= */
.brand-side {
flex: 1.25;
background: linear-gradient(160deg, #0f172a 0%, #1e3a5f 40%, #0c4a6e 100%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 60px 72px;
position: relative;
overflow: hidden;
}
.brand-side::before {
content: "";
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(56,189,248,.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(56,189,248,.08) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
}
.brand-side::after {
content: "";
position: absolute;
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(56,189,248,.15) 0%, transparent 65%);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: pulse 6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: .7; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
}
.brand-content { position: relative; z-index: 1; width: 100%; max-width: 620px; }
.brand-logo {
display: flex; align-items: center; gap: 14px;
margin-bottom: 52px;
}
.brand-logo img { height: 42px; width: 42px; border-radius: 9px; }
.brand-logo .brand-name {
font-size: 27px; font-weight: 700; color: white; letter-spacing: .5px;
line-height: 1.2;
}
.brand-logo .brand-sub {
font-size: 12.5px; color: rgba(148,163,184,.85); letter-spacing: 1.5px;
margin-top: 3px;
}
/* 主标语 — 一行 */
.tagline-cn {
font-size: 30px; font-weight: 700; color: white;
line-height: 1.4; margin-bottom: 14px;
white-space: nowrap;
}
.tagline-cn .highlight {
background: linear-gradient(90deg, #38bdf8, #818cf8);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.tagline-en {
font-size: 13.5px; color: rgba(148,163,184,.75); letter-spacing: .3px;
margin-bottom: 44px;
}
/* 能力标签 — 横向一行 */
.cap-row {
display: flex; flex-wrap: nowrap; gap: 8px;
}
.cap {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 13px;
background: rgba(56,189,248,.08);
border: 1px solid rgba(56,189,248,.22);
border-radius: 100px;
font-size: 12.5px; color: rgba(226,232,240,.92);
backdrop-filter: blur(4px);
transition: all .2s;
white-space: nowrap;
flex-shrink: 0;
}
.cap:hover {
background: rgba(56,189,248,.15);
border-color: rgba(56,189,248,.4);
}
.cap .dot {
width: 6px; height: 6px; border-radius: 50%;
background: #38bdf8; flex-shrink: 0;
box-shadow: 0 0 8px rgba(56,189,248,.8);
}
.cap.secure .dot { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.8); }
.cap.secure:hover { background: rgba(52,211,153,.13); border-color: rgba(52,211,153,.38); }
/* 引擎列表 */
.engine-line {
margin-top: 28px;
padding-top: 24px;
border-top: 1px solid rgba(148,163,184,.15);
}
.engine-line .el-label {
font-size: 11.5px; color: rgba(148,163,184,.6);
letter-spacing: 2px; margin-bottom: 12px;
}
.engine-line .el-items {
display: flex; flex-wrap: wrap; gap: 8px;
}
.engine-line .el {
font-size: 12px; color: rgba(203,213,225,.75);
padding: 5px 11px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.08);
border-radius: 6px;
}
/* ========= 右栏:登录卡片 ========= */
.login-side {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 60px;
}
.login-card { width: 380px; }
.login-card .hello {
font-size: 15px; color: #64748b; margin-bottom: 8px;
}
.login-card .title {
font-size: 24px; font-weight: 700; color: #0f172a; margin-bottom: 36px;
}
label { display: block; font-size: 13px; color: #334155; margin-bottom: 8px; font-weight: 500; }
.input-wrap { position: relative; margin-bottom: 20px; }
.input-wrap input {
width: 100%; padding: 12px 14px 12px 40px;
border: 1.5px solid #e2e8f0; border-radius: 10px;
font-size: 14px; background: #f8fafc;
transition: all .2s;
}
.input-wrap input:focus {
border-color: #3b82f6; background: white;
box-shadow: 0 0 0 3px rgba(59,130,246,.12); outline: none;
}
.input-wrap .input-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
color: #94a3b8; font-size: 16px;
}
button.login-btn {
width: 100%; padding: 13px;
background: linear-gradient(135deg, #1e3a5f, #3b82f6);
color: white; border: none; border-radius: 10px;
font-size: 15px; font-weight: 600; cursor: pointer;
transition: all .2s;
margin-top: 8px;
}
button.login-btn:hover {
background: linear-gradient(135deg, #244773, #2563eb);
box-shadow: 0 8px 24px rgba(59,130,246,.3);
transform: translateY(-1px);
}
button.login-btn:active { transform: translateY(0); }
.error {
background: #fef2f2; color: #dc2626;
padding: 12px 14px; border-radius: 10px;
font-size: 13px; margin-bottom: 20px;
border: 1px solid #fecaca;
display: flex; align-items: center; gap: 8px;
}
.footer {
margin-top: 40px; text-align: center;
color: #94a3b8; font-size: 12px;
}
.footer .divider {
display: inline-block; width: 40px; height: 1px; background: #e2e8f0;
vertical-align: middle; margin: 0 12px;
}
@media (max-width: 1000px) {
.tagline-cn { font-size: 24px; white-space: normal; }
}
@media (max-width: 900px) {
body { flex-direction: column; }
.brand-side { padding: 48px 32px; flex: none; min-height: 320px; }
.engine-line { display: none; }
.login-side { padding: 32px; }
}
</style>
</head>
<body>
<!-- ========== 左栏:品牌区 ========== -->
<div class="brand-side">
<div class="brand-content">
<div class="brand-logo">
<img src="/static/logo.png" alt="MeshSafe">
<div>
<div class="brand-name">MeshSafe</div>
<div class="brand-sub">文件摆渡安全卫士</div>
</div>
</div>
<div class="tagline-cn">
多层检测,<span class="highlight">双向管控</span>,文件摆渡更安心
</div>
<div class="tagline-en">Multi-layer inspection · Bidirectional control · Safer file exchange</div>
<div class="cap-row">
<div class="cap"><span class="dot"></span>6 层引擎交叉检测</div>
<div class="cap secure"><span class="dot"></span>故障默认拦截</div>
<div class="cap"><span class="dot"></span>零容忍规则优先响应</div>
<div class="cap secure"><span class="dot"></span>全链路审计追溯</div>
</div>
<div class="engine-line">
<div class="el-label">DETECTION ENGINES</div>
<div class="el-items">
<div class="el">文件结构分析</div>
<div class="el">ClamAV 病毒库</div>
<div class="el">文档深度检测</div>
<div class="el">关键词匹配</div>
<div class="el">YARA 规则匹配</div>
<div class="el">PE/二进制分析</div>
</div>
</div>
</div>
</div>
<!-- ========== 右栏:登录卡片 ========== -->
<div class="login-side">
<div class="login-card">
<div class="hello">欢迎回来</div>
<div class="title">登录 MeshSafe</div>
{% if error %}
<div class="error">
<span>⚠️</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/console/login">
<div class="input-wrap">
<span class="input-icon">👤</span>
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
</div>
<div class="input-wrap">
<span class="input-icon">🔒</span>
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
</div>
<button type="submit" class="login-btn">登 录</button>
</form>
<div class="footer">
<span>© MeshSafe</span>
<span class="divider"></span>
<span>文件摆渡 · 内外网隔离</span>
</div>
</div>
</div>
</body>
</html>

223
app/templates/policy.html Normal file
View File

@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>策略配置 - MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f5f6fa; color: #333; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
h2 { margin: 0 0 16px; font-size: 18px; }
.tabs { display: flex; border-bottom: 2px solid #e0e0e0; margin-bottom: 16px; }
.tab { padding: 10px 20px; cursor: pointer; font-size: 14px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; }
.tab.active { color: #1a2a6c; border-bottom-color: #1a2a6c; font-weight: 500; }
.panel { display: none; }
.panel.active { display: block; }
.card { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.06); margin-bottom: 16px; }
.card h3 { margin: 0 0 12px; font-size: 14px; color: #555; font-weight: 600; }
textarea { width: 100%; min-height: 480px; font-family: "JetBrains Mono", Consolas, monospace;
font-size: 13px; padding: 14px; border: 1px solid #ddd; border-radius: 6px;
resize: vertical; line-height: 1.5; }
textarea:focus { border-color: #1a2a6c; outline: none; }
.toolbar { display: flex; gap: 10px; margin-top: 12px; align-items: center; }
button { padding: 9px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.btn-primary { background: #1a2a6c; color: white; font-weight: 500; }
.btn-primary:hover { background: #243a8c; }
.btn-ghost { background: #f0f0f0; color: #555; }
.btn-ghost:hover { background: #e4e4e4; }
.btn-danger { background: #c62828; color: white; }
.btn-danger:hover { background: #d32f2f; }
.versions { width: 100%; border-collapse: collapse; font-size: 13px; }
.versions th { background: #f8f9fa; padding: 8px 12px; text-align: left; color: #666;
font-weight: 500; border-bottom: 1px solid #eee; }
.versions td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-family: monospace; }
.chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px;
margin: 2px; }
.chip-zt { background: #ffebee; color: #c62828; }
.chip-warn { background: #fff3e0; color: #ef6c00; }
.chip-info { background: #e3f2fd; color: #1565c0; }
.chip-ok { background: #e8f5e9; color: #2e7d32; }
.form-row { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.form-row label { width: 160px; font-size: 13px; color: #555; }
.form-row input[type=number], .form-row input[type=text] { padding: 8px 10px; border: 1px solid #ddd;
border-radius: 5px; font-size: 13px; width: 120px; }
</style>
</head>
<body>
{% set nav_active = 'policy' %}{% include "_nav.html" %}
<main>
<div class="tabs">
<div class="tab active" data-tab="yaml">YAML 模式(直接编辑)</div>
<div class="tab" data-tab="rules">规则清单(只读预览)</div>
<div class="tab" data-tab="yara">YARA 规则库({{ yara_loaded_count }} 条已加载)</div>
<div class="tab" data-tab="versions">版本历史</div>
</div>
<!-- ============ YAML 模式 ============ -->
<div id="panel-yaml" class="panel active">
<form method="post" action="/console/policy/save">
<input type="hidden" name="mode" value="yaml">
<div class="card">
<h3>策略内容(数据库为准,YAML 作为导出备份)</h3>
<textarea name="yaml_content" spellcheck="false">{{ yaml_text }}</textarea>
<div class="toolbar">
<button type="submit" class="btn-primary" {% if user.role != 'admin' %}disabled title="admin 权限才能保存"{% endif %}>保存并热重载</button>
<button type="button" class="btn-ghost" onclick="document.querySelector('#panel-yaml textarea').value = document.querySelector('#panel-yaml textarea').value;">刷新</button>
<span style="color:#888;font-size:12px;">当前版本:<b>{{ policy.version }}</b> · SHA256:{{ policy.sha256[:12] }}...</span>
</div>
</div>
</form>
</div>
<!-- ============ 规则清单 ============ -->
<div id="panel-rules" class="panel">
<div class="card">
<h3>零容忍规则集({{ policy.zero_tolerance_rules|length }} 条 · 命中即拒绝)</h3>
<div>
{% if zt_rules_cn %}
{% for r in zt_rules_cn %}
<span class="chip chip-zt" title="{{ r.rule }} — {{ r.cn_desc }}">{{ r.cn_name }}</span>
{% endfor %}
{% else %}
{% for r in policy.zero_tolerance_rules %}
<span class="chip chip-zt">{{ r }}</span>
{% endfor %}
{% endif %}
</div>
</div>
<div class="card">
<h3>策略全局参数</h3>
<table class="versions">
<tr><th>参数名</th><th>说明</th><th>当前值</th></tr>
<tr><td>block_score</td><td>单引擎累计得分 ≥ 此阈值 → 判定为 malicious 直接拒绝</td><td><b>{{ policy.block_score }}</b></td></tr>
<tr><td>review_score</td><td>累计得分落在这个区间 → 判定为 suspicious 转人工复核</td><td>{{ policy.review_score[0] }} ~ {{ policy.review_score[1] }}</td></tr>
<tr><td>engine_failure_mode</td><td>检测引擎异常时的处理策略(fail_closed=拒绝 fail_open=放行)</td><td><b style="color:#c62828;">{{ policy.engine_failure_mode }}</b></td></tr>
<tr><td>max_file_size</td><td>允许上传的单文件最大体积(超过直接拒绝)</td><td>{% set kb = policy.max_file_size / 1024 %}{% set mb = kb / 1024 %}{% if mb >= 1 %}{{ "%.1f"|format(mb) }} MB{% else %}{{ "%.0f"|format(kb) }} KB{% endif %}({{ policy.max_file_size }} 字节)</td></tr>
<tr><td>allow_encrypted_archive</td><td>是否允许带密码的加密 ZIP/RAR/7z 压缩包通过</td><td>{% if policy.allow_encrypted_archive %}<span class="chip chip-ok">允许</span>{% else %}<span class="chip chip-zt">禁止</span>{% endif %}</td></tr>
<tr><td>allow_encrypted_document</td><td>是否允许带密码的加密 Office 文档(.docx/.xlsx/.pptx)通过</td><td>{% if policy.allow_encrypted_document %}<span class="chip chip-ok">允许</span>{% else %}<span class="chip chip-zt">禁止</span>{% endif %}</td></tr>
<tr><td>白名单扩展名</td><td>允许上传的文件扩展名(不在此列表的直接拒绝)</td><td style="font-family:sans-serif;">
{% for e in policy.allowed_extensions %}
<span class="chip chip-info">{{ e }}</span>
{% endfor %}
</td></tr>
</table>
</div>
{% if policy.keyword_rules %}
<div class="card">
<h3>词表规则({{ policy.keyword_rules|length }} 条)</h3>
{% for kw in policy.keyword_rules %}
<div class="chip chip-info">{{ kw.name }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- ============ YARA 规则库 ============ -->
<div id="panel-yara" class="panel">
<div class="card">
<h3>YARA 规则库({{ yara_files_count }} 个文件 · {{ yara_loaded_count }} 条已编译)</h3>
{% if yara_rules %}
<table class="versions">
<thead>
<tr>
<th>规则名</th>
<th>分类</th>
<th>风险等级</th>
<th>误报率</th>
<th>权重</th>
<th>触发次数</th>
<th>参考</th>
</tr>
</thead>
<tbody>
{% for r in yara_rules %}
<tr>
<td style="font-family:monospace;font-size:12px;" title="{{ r.name }}">{{ r.name }}</td>
<td>
<span class="chip chip-info" title="分类代码 {{ r.category }}">{{ r.category_cn }}</span>
</td>
<td>
{% if r.risk_level_cn %}<span class="chip {{ r.risk_level_chip }}">{{ r.risk_level_cn }}</span>
{% else %}<span class="chip chip-ok">{{ r.risk_level }}</span>{% endif %}
</td>
<td style="color:#888;font-size:12px;">{{ r.false_positive_rate }}</td>
<td><b>{{ r.weight }}</b></td>
<td>
{% if r.trigger_count > 0 %}<span class="chip chip-zt">{{ r.trigger_count }}</span>
{% else %}<span style="color:#bbb;">0</span>{% endif %}
</td>
<td style="font-size:11px;color:#888;">{{ r.reference[:50] }}{% if r.reference|length > 50 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="color:#999;text-align:center;padding:30px;">
{% if yara_rules_dir %}
YARA 规则库目录:<code>{{ yara_rules_dir }}</code><br>
目录内 {{ yara_files_count }} 个 .yar 文件
{% if yara_files_count == 0 %}—— 请检查规则文件是否存在{% endif %}
{% else %}
YARA 规则库未加载(可能 yara-python 未安装或规则目录不存在)
{% endif %}
</div>
{% endif %}
</div>
<div class="card" style="margin-top:12px;">
<h3>权重分档说明(来自 policy.yaml → yara_rule_weights)</h3>
<div style="font-size:13px;color:#555;line-height:1.8;">
<span class="chip chip-zt">critical → weight=85</span>
<span class="chip chip-warn">high → weight=65</span>
<span class="chip chip-info">medium → weight=45</span>
<span class="chip chip-ok">low → weight=25</span>
<span style="color:#888;margin-left:12px;">未配置的规则默认 weight=50</span>
</div>
</div>
</div>
<!-- ============ 版本历史 ============ -->
<div id="panel-versions" class="panel">
<div class="card">
<h3>策略版本历史({{ versions|length }} 条)</h3>
<table class="versions">
<thead><tr><th>版本</th><th>SHA256</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
{% for v in versions %}
<tr>
<td>{{ v.version }}</td>
<td>{{ v.sha256 }}</td>
<td>{{ v.created_at|bj_time if v.created_at else '' }}</td>
<td>
{% if user.role == 'admin' %}
<form method="post" action="/console/policy/rollback/{{ v.version }}" style="display:inline;">
<button type="submit" class="btn-danger" style="padding:4px 12px;font-size:12px;">回滚到此版本</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="4" style="text-align:center;color:#999;">暂无版本记录(首次部署执行 scripts.init_db)</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</main>
<script>
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('panel-' + tab.dataset.tab).classList.add('active');
};
});
</script>
</body>
</html>

232
app/templates/tasks.html Normal file
View File

@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>任务历史 - MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1500px; margin: 0 auto; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 18px; }
.page-head h1 { margin: 0 0 6px; font-size: 21px; font-weight: 700; }
.page-head .sub { font-size: 13px; color: #64748b; }
.page-head .count-chip {
background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 14px; font-size: 12.5px; color: #475569;
}
.page-head .count-chip b { color: #0f172a; }
.panel { background: #fff; border-radius: 12px; padding: 18px 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 10px 12px; text-align: left; font-size: 11.5px;
color: #64748b; font-weight: 600; border-bottom: 1px solid #e8edf4; white-space: nowrap; }
td { padding: 10px 12px; font-size: 12.5px; border-bottom: 1px solid #f4f7fb; color: #334155; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #fafbfd; }
td.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #64748b; }
td.fname { font-weight: 500; color: #1e293b; max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.verdict-cell { display: flex; align-items: center; gap: 6px; }
.mode-badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11px; }
.mode-sync { background: #e0f2fe; color: #0369a1; }
.mode-async { background: #f3e5f5; color: #7b1fa2; }
details > summary { cursor: pointer; color: #2563eb; user-select: none; font-size: 12px; }
details > summary:hover { text-decoration: underline; }
/* 详情列:限制宽度,内层内容横向滚动,不撑开外层表格 */
tbody td:last-child { min-width: 220px; max-width: 360px; vertical-align: top; }
details { display: block; width: 100%; }
details .findings { padding: 12px; font-size: 12px; background: #f8fafc;
border-radius: 8px; margin-top: 6px;
max-width: 100%; overflow-x: auto; overflow-y: visible; display: block; }
details .findings table { border: 1px solid #e8edf4; border-radius: 6px; overflow: hidden; min-width: 600px; }
details .findings th { font-size: 11px; padding: 6px 10px; white-space: nowrap; }
details .findings td { font-size: 11.5px; padding: 6px 10px; white-space: nowrap; }
.empty { text-align: center; padding: 50px; color: #94a3b8; font-size: 13px; }
/* ---------- 分页 ---------- */
.pager { display: flex; align-items: center; justify-content: space-between; margin-top: 16px;
padding-top: 14px; border-top: 1px solid #eef2f7; }
.pager .p-info { font-size: 12.5px; color: #64748b; }
.pager .p-info b { color: #0f172a; }
.pager .p-nav { display: flex; gap: 5px; align-items: center; }
.pager .p-nav a, .pager .p-nav span {
min-width: 32px; height: 30px; padding: 0 10px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 7px; font-size: 12.5px; text-decoration: none;
border: 1px solid #e2e8f0; color: #475569; background: #fff;
transition: all .15s;
}
.pager .p-nav a:hover { border-color: #93c5fd; color: #2563eb; background: #eff6ff; }
.pager .p-nav .cur { background: #1e3a5f; border-color: #1e3a5f; color: #fff; font-weight: 600; }
.pager .p-nav .disabled { opacity: .4; pointer-events: none; }
.pager .p-nav .dots { border: none; background: none; min-width: 20px; padding: 0; color: #94a3b8; }
/* ---------- 风险体系说明 ---------- */
.risk-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 14px; padding-top: 14px;
border-top: 1px dashed #e8edf4; }
.risk-legend .rl { display: flex; align-items: flex-start; gap: 7px; font-size: 11.5px; color: #94a3b8; }
.risk-legend .rl .rl-name { color: #334155; font-weight: 600; font-size: 12px; white-space: nowrap; }
</style>
</head>
<body>
{% set nav_active = 'tasks' %}
{% include "_nav.html" %}
<main>
<div class="page-head">
<div>
<h1>任务历史</h1>
<div class="sub">全部扫描任务记录 · 每页 {{ page_size }} 条</div>
</div>
<div class="count-chip">
共 <b>{{ total }}</b> 条记录 · 第 <b>{{ page }}</b> / {{ total_pages }} 页
</div>
</div>
<div class="panel">
{% if tasks %}
<table>
<thead>
<tr>
<th>时间</th>
<th>任务 ID</th>
<th>文件名</th>
<th>来源渠道</th>
<th>类型</th>
<th>大小</th>
<th>模式</th>
<th>检测结论</th>
<th>处置动作</th>
<th>风险分</th>
<th>耗时</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in tasks %}
<tr id="task-{{ t.id }}">
<td class="mono">{{ t.created_at|bj_time("%m-%d %H:%M:%S") if t.created_at else '' }}</td>
<td class="mono" title="{{ t.id }}">{{ t.id[0:10] }}...</td>
<td class="fname" title="{{ t.filename }}">{{ t.filename }}</td>
<td class="mono" title="{{ t.client_id }}">{{ t.channel|cn_channel }}</td>
<td class="mono" title="{{ t.real_type }}">{{ t.real_type[:16] if t.real_type else t.declared_ext }}</td>
<td class="mono">
{% if t.size > 1048576 %}{{ (t.size / 1048576)|round(1) }} MB
{% elif t.size > 1024 %}{{ (t.size / 1024)|round(1) }} KB
{% else %}{{ t.size }} B{% endif %}
</td>
<td><span class="mode-badge mode-{{ t.mode }}">{{ t.mode|cn_mode }}</span></td>
<td>
<div class="verdict-cell">
<span class="tag {{ t.verdict|cn_verdict_badge }}">{{ t.verdict|cn_verdict }}</span>
</div>
</td>
<td><span class="tag plain">{{ t.action|cn_action }}</span></td>
<td class="mono">{{ "%.0f"|format(t.score) }}</td>
<td class="mono">{{ t.duration_ms }} ms</td>
<td>
<details>
<summary>详情 ({{ t.findings|length }})</summary>
<div class="findings">
{% if t.findings %}
<table>
<thead>
<tr><th>检测引擎</th><th>严重度</th><th>命中规则</th><th>权重</th><th>细节</th></tr>
</thead>
<tbody>
{% for f in t.findings %}
<tr>
<td><span class="tag plain">{{ f.engine|cn_engine }}</span></td>
<td><span class="tag {{ f.severity|cn_severity_badge }}">{{ f.severity|cn_severity }}</span></td>
<td class="mono">{{ f.rule }}</td>
<td class="mono">{{ f.weight }}</td>
<td>{{ f.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="color:#94a3b8;font-size:12px;">未检出任何风险信号</div>
{% endif %}
{% if t.sha256 %}
<div style="margin-top:10px;font-size:11px;color:#94a3b8;font-family:monospace;line-height:1.8;">
SHA256:{{ t.sha256 }}
{% if t.md5 %}<br>MD5:{{ t.md5 }}{% endif %}
<br>策略版本:{{ t.policy_version }}
{% if t.reason %}<br>裁决依据:{{ t.reason }}{% endif %}
</div>
{% endif %}
</div>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- 风险体系说明 -->
<div class="risk-legend">
{% for r in RISK_MATRIX %}
<div class="rl">
<span class="tag risk-{{ r.key }}">{{ r.label }}</span>
<span>{{ r.desc }}</span>
</div>
{% endfor %}
</div>
<!-- 分页 -->
<div class="pager">
<div class="p-info">
显示第 <b>{{ (page - 1) * page_size + 1 }}</b> - <b>{{ [page * page_size, total]|min }}</b> 条,共 <b>{{ total }}</b> 条
</div>
<div class="p-nav">
{% if has_prev %}
<a href="/console/tasks?page={{ prev_page }}">上一页</a>
{% else %}
<span class="disabled">上一页</span>
{% endif %}
{% if page_nums and page_nums[0] > 1 %}
<a href="/console/tasks?page=1">1</a>
{% if page_nums[0] > 2 %}<span class="dots">···</span>{% endif %}
{% endif %}
{% for p in page_nums %}
{% if p == page %}
<span class="cur">{{ p }}</span>
{% else %}
<a href="/console/tasks?page={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if page_nums and page_nums[-1] < total_pages %}
{% if page_nums[-1] < total_pages - 1 %}<span class="dots">···</span>{% endif %}
<a href="/console/tasks?page={{ total_pages }}">{{ total_pages }}</a>
{% endif %}
{% if has_next %}
<a href="/console/tasks?page={{ next_page }}">下一页</a>
{% else %}
<span class="disabled">下一页</span>
{% endif %}
</div>
</div>
{% else %}
<div class="empty">
<div style="font-size:28px;margin-bottom:12px;">📭</div>
暂无扫描记录。调用 <code>/api/v1/scans</code> 提交文件后,结果会自动出现在这里。
</div>
{% endif %}
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe 控制台 — 测试靶场</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f5f6fa; color: #333; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
h2 { margin: 0 0 16px; font-size: 18px; }
.summary { display: flex; gap: 16px; margin-bottom: 20px; }
.summary .card { flex: 1; background: white; border-radius: 10px; padding: 16px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,.06); text-align: center; }
.summary .num { font-size: 32px; font-weight: 700; }
.summary .lbl { font-size: 12px; color: #888; margin-top: 4px; }
.summary .total .num { color: #1a2a6c; }
.summary .pass .num { color: #2e7d32; }
.summary .fail .num { color: #c62828; }
table { width: 100%; border-collapse: collapse; background: white;
border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
th, td { padding: 10px 14px; text-align: left; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
th { background: #f8f9fd; font-weight: 500; color: #555; }
tr:hover { background: #fafbff; }
.chip { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; font-weight: 500; white-space: nowrap; }
.chip-zt { background: #ffebee; color: #c62828; }
.chip-warn { background: #fff3e0; color: #ef6c00; }
.chip-ok { background: #e8f5e9; color: #2e7d32; }
.chip-info { background: #e3f2fd; color: #1565c0; }
.chip-gray { background: #f0f0f0; color: #666; }
.pass-mark { color: #2e7d32; font-size: 16px; }
.fail-mark { color: #c62828; font-size: 16px; }
details { background: #fafbff; }
details summary { cursor: pointer; padding: 6px 12px; font-size: 12px; color: #1565c0; }
details[open] summary { border-bottom: 1px solid #e3f2fd; }
.findings-table { margin: 8px 12px; }
.findings-table th { background: #eef2fb; font-size: 12px; padding: 6px 10px; }
.findings-table td { padding: 5px 10px; font-size: 12px; }
.refresh-btn { display: inline-block; padding: 8px 20px; background: #1a2a6c;
color: white; border-radius: 6px; text-decoration: none; font-size: 13px; }
.refresh-btn:hover { background: #0f1d52; }
.clamav-note { background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
padding: 10px 16px; font-size: 13px; color: #5d4037; margin-bottom: 16px; }
</style>
</head>
<body>
{% set nav_active = 'test' %}{% include "_nav.html" %}
<main>
<input type="file" id="uploadInput" multiple style="display:none">
{% if results %}
<div class="summary">
<div class="card total"><div class="num">{{ total }}</div><div class="lbl">样本总数</div></div>
<div class="card pass"><div class="num">{{ pass_count }}</div><div class="lbl">检测通过 ✅</div></div>
<div class="card fail"><div class="num">{{ fail_count }}</div><div class="lbl">检测未通过 ❌</div></div>
<div class="card" style="text-align:center;">
<a class="refresh-btn" href="/console/test?rescan=1">🔄 重新扫描</a>
<button id="uploadBtn" class="refresh-btn" style="background:#2e7d32; margin-left:8px;">📤 上传样本</button>
</div>
</div>
<div style="display:flex; gap:16px; margin-bottom:14px; font-size:12px; color:#64748b; align-items:center;">
<span>📦 当前策略:<b style="color:#1e293b;">{{ policy_version or '-' }}</b></span>
{% if last_run_at %}
<span>🕐 上次运行:<b style="color:#1e293b;">{{ last_run_at|bj_time("%m-%d %H:%M:%S") }}</b></span>
{% else %}
<span style="color:#f59e0b;">⚠️ 从未运行过 —— 点「重新扫描」开始检测</span>
{% endif %}
{% if rescan %}<span style="color:#0369a1;">✅ 本次为实时全量扫描结果</span>{% endif %}
</div>
{% if results[0].clamav_error %}
<div class="clamav-note">
⚠️ ClamAV 引擎不可用(本地未起 clamd)—— 部分 verdict 显示为 <code>error (ClamAV 不可用)</code>,
但 YARA / structure / documents / keywords 引擎已正常工作。生产环境 docker compose 起 ClamAV 后会自动加载。
</div>
{% endif %}
<table>
<thead>
<tr>
<th style="width:40px;">#</th>
<th style="width:50px;">结果</th>
<th>样本文件</th>
<th style="width:80px;">分类</th>
<th style="width:180px;">期望裁决 · 规则</th>
<th style="width:200px;">检测结论 · 规则</th>
<th style="width:60px;">方向</th>
<th style="width:60px;">规则</th>
<th style="width:60px;">Score</th>
<th style="width:70px;">耗时</th>
<th style="width:80px;">Findings</th>
</tr>
</thead>
<tbody>
{% for r in results %}
<tr>
<td style="color:#999;font-size:12px;">{{ loop.index }}</td>
<td class="{% if r.overall_ok %}pass-mark{% else %}fail-mark{% endif %}">
{% if r.overall_ok %}✅{% else %}❌{% endif %}
</td>
<td>
<div style="font-family:monospace;font-size:12px;">{{ r.filename }}</div>
<div style="color:#888;font-size:11px;">{{ r.description }}</div>
</td>
<td><span class="chip chip-info">{{ r.category }}</span></td>
<td>
<!-- 期望裁决:完整显示 expected_verdict -->
{% set exp_v = r.expected_verdict or '' %}
{% if exp_v in ('reject', 'malicious') %}
<span class="tag bad">拦截 {{ exp_v }}</span>
{% elif exp_v == 'suspicious' %}
<span class="chip chip-warn">可疑</span>
{% elif exp_v == 'pass' or exp_v == 'clean' %}
<span class="tag ok">放行 {{ exp_v }}</span>
{% else %}
<span class="chip chip-gray">{{ exp_v or '未设' }}</span>
{% endif %}
<!-- 期望规则 -->
{% if r.expected_rules %}
<div style="margin-top:4px;">
{% for rule in r.expected_rules %}
<span class="chip chip-gray" style="font-family:monospace;font-size:10px;">{{ rule }}</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
<!-- 实际裁决 -->
<span class="tag {{ r.actual_verdict|cn_verdict_badge }}" title="{{ r.actual_verdict }}">{{ r.actual_verdict|cn_verdict }}</span>
{% if r.clamav_error %}<div style="font-size:10px;color:#ef6c00;">ClamAV 不可用</div>{% endif %}
<!-- 实际规则 -->
{% if r.actual_rules %}
<div style="margin-top:4px;">
{% for rule in r.actual_rules %}
<span class="chip chip-{% if rule in r.expected_rules %}ok{% else %}info{% endif %}" style="font-family:monospace;font-size:10px;">{{ rule }}</span>
{% endfor %}
</div>
{% elif r.actual_verdict in ('pass', 'clean') %}
<div style="margin-top:4px;color:#aaa;font-size:11px;">(无规则命中)</div>
{% else %}
<div style="margin-top:4px;color:#f59e0b;font-size:11px;">⚠️ 无规则</div>
{% endif %}
</td>
<td style="text-align:center;">
{% if r.verdict_match %}
<span style="color:#2e7d32;font-size:16px;" title="判定方向匹配">✅</span>
{% else %}
<span style="color:#c62828;font-size:16px;" title="判定方向偏离">❌</span>
{% endif %}
</td>
<td style="text-align:center;">
{% if r.rule_hit %}
<span style="color:#2e7d32;font-size:16px;" title="规则命中">✅</span>
{% else %}
<span style="color:#c62828;font-size:16px;" title="规则未命中">❌</span>
{% endif %}
</td>
<td><b>{{ "%.0f"|format(r.score) }}</b></td>
<td style="color:#888;">{{ r.duration_ms }}ms</td>
<td>
<details>
<summary>{{ r.findings_count }} 条</summary>
{% if r.findings %}
<table class="findings-table">
<thead><tr><th>引擎</th><th>规则</th><th>严重度</th><th>权重</th><th>细节</th></tr></thead>
<tbody>
{% for f in r.findings %}
<tr>
<td><span class="chip chip-info">{{ f.engine }}</span></td>
<td style="font-family:monospace;">{{ f.rule }}</td>
<td>{{ f.severity }}</td>
<td>{{ f.weight }}</td>
<td style="color:#888;font-size:11px;">{{ f.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align:center;padding:60px;color:#999;">
测试样本目录不存在:<code>{{ samples_dir }}</code><br>
请运行:<code>python scripts/generate_test_samples.py</code>
</div>
{% endif %}
<!-- 用户自定义上传扫描结果区 -->
<div id="uploadResultArea" style="display:none; margin-top:24px;">
<h2 style="display:flex;align-items:center;gap:10px;">
📤 我的上传扫描结果
<span id="uploadSummary" style="font-size:13px;color:#888;font-weight:normal;"></span>
</h2>
<table id="uploadTable" style="margin-bottom:16px;">
<thead>
<tr>
<th style="width:40px;">#</th>
<th style="width:30px;">结果</th>
<th>文件</th>
<th style="width:90px;">大小</th>
<th style="width:110px;">verdict</th>
<th style="width:70px;">Score</th>
<th style="width:80px;">耗时</th>
<th>Findings</th>
</tr>
</thead>
<tbody id="uploadTableBody"></tbody>
</table>
</div>
</main>
<script>
(function() {
const btn = document.getElementById('uploadBtn');
const input = document.getElementById('uploadInput');
btn.addEventListener('click', function() { input.click(); });
input.addEventListener('change', async function() {
const files = Array.from(input.files);
if (!files.length) return;
btn.disabled = true;
btn.textContent = '⏳ 扫描中 ' + files.length + ' 个文件...';
input.value = ''; // reset 允许重复选同文件
const fd = new FormData();
files.forEach(f => fd.append('files', f));
try {
const resp = await fetch('/console/test/upload', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await resp.json();
renderUploadResults(data.results || []);
} catch (err) {
alert('上传扫描失败: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '📤 上传样本';
}
});
function renderUploadResults(results) {
const area = document.getElementById('uploadResultArea');
const tbody = document.getElementById('uploadTableBody');
const summary = document.getElementById('uploadSummary');
area.style.display = 'block';
tbody.innerHTML = '';
let pass = 0, fail = 0;
results.forEach((r, i) => {
const icon = r.verdict === 'reject' || r.verdict === 'malicious' ? '❌' :
r.verdict === 'error' ? '⚠️' : '✅';
if (r.verdict === 'reject' || r.verdict === 'malicious' || r.verdict === 'error') fail++;
else pass++;
const sizeKB = r.size < 1024 ? r.size + ' B' : (r.size / 1024).toFixed(1) + ' KB';
const findingsHtml = r.findings && r.findings.length
? '<details><summary>' + r.findings.length + ' 条 findings</summary>' +
'<table class="findings-table"><thead><tr><th>引擎</th><th>规则</th><th>严重度</th><th>权重</th><th>细节</th></tr></thead><tbody>' +
r.findings.map(f =>
'<tr><td><span class="chip chip-info">' + escapeHtml(f.engine) + '</span></td>' +
'<td style="font-family:monospace;">' + escapeHtml(f.rule) + '</td>' +
'<td>' + escapeHtml(f.severity) + '</td>' +
'<td>' + escapeHtml(String(f.weight)) + '</td>' +
'<td style="color:#888;font-size:11px;">' + escapeHtml(f.detail) + '</td></tr>'
).join('') +
'</tbody></table></details>'
: '<span style="color:#aaa;">(无)</span>';
const clamavNote = r.clamav_error
? '<div style="font-size:10px;color:#ef6c00;">ClamAV 不可用</div>'
: '';
tbody.insertAdjacentHTML('beforeend',
'<tr>' +
'<td style="color:#999;font-size:12px;">' + (i + 1) + '</td>' +
'<td>' + icon + '</td>' +
'<td>' +
'<div style="font-family:monospace;font-size:12px;">' + escapeHtml(r.filename) + '</div>' +
(r.reason ? '<div style="color:#c62828;font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;">' + escapeHtml(r.reason) + '</div>' : '') +
'</td>' +
'<td style="color:#888;font-size:12px;">' + sizeKB + '</td>' +
'<td><span class="tag ' + (r.verdict_badge || 'muted') + '" title="' + escapeHtml(r.verdict) + '">' + escapeHtml(r.verdict_cn || r.verdict) + '</span>' + clamavNote + '</td>' +
'<td><b>' + (r.score || 0).toFixed(0) + '</b></td>' +
'<td style="color:#888;">' + (r.duration_ms || 0) + 'ms</td>' +
'<td>' + findingsHtml + '</td>' +
'</tr>'
);
});
summary.textContent = '✅ ' + pass + ' 通过 / ❌ ' + fail + ' 未通过 / 共 ' + results.length + ' 个文件';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
})();
</script>
</body>
</html>