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:
3
app/__init__.py
Normal file
3
app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""MeshSafe 文件安全检测服务。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
150
app/config.py
Normal file
150
app/config.py
Normal 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
1782
app/console.py
Normal file
File diff suppressed because it is too large
Load Diff
41
app/db.py
Normal file
41
app/db.py
Normal 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()
|
||||
11
app/diagnostic/__init__.py
Normal file
11
app/diagnostic/__init__.py
Normal 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
171
app/diagnostic/config.py
Normal 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,
|
||||
},
|
||||
}
|
||||
126
app/diagnostic/docker_probe.py
Normal file
126
app/diagnostic/docker_probe.py
Normal 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
477
app/diagnostic/dumper.py
Normal 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
228
app/diagnostic/engine.py
Normal 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
878
app/diagnostic/rules.py
Normal 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
268
app/diagnostic/scheduler.py
Normal 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
455
app/diagnostic/storage.py
Normal 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
1
app/engines/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""引擎包。"""
|
||||
123
app/engines/base.py
Normal file
123
app/engines/base.py
Normal 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
302
app/engines/binary.py
Normal 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
284
app/engines/clamav.py
Normal 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
1001
app/engines/documents.py
Normal file
File diff suppressed because it is too large
Load Diff
383
app/engines/extractor.py
Normal file
383
app/engines/extractor.py
Normal 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
157
app/engines/keywords.py
Normal 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
1371
app/engines/structure.py
Normal file
File diff suppressed because it is too large
Load Diff
281
app/i18n_labels.py
Normal file
281
app/i18n_labels.py
Normal 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
87
app/logging_config.py
Normal 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
568
app/main.py
Normal 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
176
app/models.py
Normal 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
1
app/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""安全包。"""
|
||||
106
app/security/crypto.py
Normal file
106
app/security/crypto.py
Normal 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
261
app/security/deps.py
Normal 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
113
app/security/ratelimit.py
Normal 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
179
app/security/signing.py
Normal 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
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""服务包。"""
|
||||
155
app/services/audit.py
Normal file
155
app/services/audit.py
Normal 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
754
app/services/policy.py
Normal 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
540
app/services/scanner.py
Normal 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
16
app/templates/_nav.html
Normal 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>
|
||||
32
app/templates/_nav_style.html
Normal file
32
app/templates/_nav_style.html
Normal 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
401
app/templates/about.html
Normal 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>
|
||||
503
app/templates/dashboard.html
Normal file
503
app/templates/dashboard.html
Normal 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>
|
||||
415
app/templates/diagnostics.html
Normal file
415
app/templates/diagnostics.html
Normal 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 }}
|
||||
· {{ docker_status_msg }}
|
||||
{% if started_at %} · 诊断启用: {{ 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>
|
||||
{{ 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
278
app/templates/login.html
Normal 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
223
app/templates/policy.html
Normal 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
232
app/templates/tasks.html
Normal 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>
|
||||
323
app/templates/test_center.html
Normal file
323
app/templates/test_center.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user