Compare commits
9 Commits
a372ee5a08
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 144431b300 | |||
| c0cb292350 | |||
| da8496adfd | |||
| 5d3c895542 | |||
| 1222238bf3 | |||
| a8f5733bb7 | |||
| efeea7e906 | |||
| d697f8a2f4 | |||
| d99d179f0b |
101
README.md
101
README.md
@@ -1,6 +1,6 @@
|
||||
# MeshSafe · 文件安全检测服务
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -200,6 +200,68 @@ MeshSafe 会**分析宏的实际行为**:
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v0.2.3 (2026-10-29)
|
||||
|
||||
**Bug 修复:**
|
||||
|
||||
- **Policy 加载失败(每次启动都回退 YAML)** — `policy.py _load_db_or_yaml()` 用 `scalar_one_or_none()` 加载最新策略版本,但 `policy_versions` 表有多条历史版本 → SQLAlchemy 抛 `Multiple rows` → 永远回退 YAML 配置文件,导致不同重启间规则数波动。修复为 `db.query(PolicyVersion).order_by(created_at.desc()).first()`,直接取最新一条 ORM 对象
|
||||
- **控制台任务历史被内部测试记录淹没** — `scan_tasks` 表累积 5,800+ 条内部对账/诊断脚本产生的测试数据(channel 包括 `test_center` / `v` / `verify` / `diag` / `debug` / `final` 等 19 个),dashboard 和 tasks_page 无过滤全部展示。修复:① DB 清理所有内部 channel 记录(保留 3,488 条生产数据);② console.py 加 `INTERNAL_TEST_CHANNELS` 黑名单常量,dashboard 6 个查询 + tasks_page 2 个查询全部 `.filter(ScanTask.channel.notin_(...))`
|
||||
- **测试靶场显示 "未知" 层和 "—" 编号** — 控制台用 `iterdir()` 遍历目录收集样本,但目录里混入 4 个 YARA 调试时的临时文件(`L4-005_hotreload.txt` 等),index.json 没登记 → category 取不到显示 "未知"。修复:① 清理 4 个临时文件;② `test_center_page` 改为只从 index.json 收集样本(目录遍历替换为 index.json 遍历),以后不会再出这个问题
|
||||
|
||||
**DB 与运维增强:**
|
||||
|
||||
- **ScanTask 复合索引** — 新增 `ix_scan_tasks_channel_created ON (channel, created_at DESC)`,解决 dashboard/tasks_page 高频查询的全表扫描。models.py `ScanTask.__table_args__` 也同步更新,新建实例会自动带上
|
||||
- **归档表 + 归档脚本** — 建 `scan_tasks_archive` 表(含 4 个索引);新增 `scripts/archive_old_tasks.py` 支持 4 项清理策略:
|
||||
- scan_tasks **90 天前**:清 detail JSONB(每条 1-5 KB,省空间)
|
||||
- scan_tasks **365 天前**:移到 scan_tasks_archive 归档表(CASCADE 删 ScanFinding)
|
||||
- idempotency_records:清 **7 天前**(幂等缓存过期可以重扫)
|
||||
- policy_versions:只留**最新 5 个**(历史版本无保留价值)
|
||||
- 支持 `--dry-run`(默认,只报告不动数据)+ `--apply`(真正执行),分批 1,000 条处理避免长事务锁表
|
||||
- **自动定时任务 Cron 服务** — docker-compose.yml 新增 `cron` 服务(共享 meshsafe:latest 镜像,零额外依赖,`restart: unless-stopped`),`scripts/cron_entrypoint.py` 纯 Python 调度器:
|
||||
- **每天 03:00** 跑 archive_old_tasks.py --apply
|
||||
- **每周一 03:10** VACUUM ANALYZE 5 张表(维护表统计信息,加速查询)
|
||||
|
||||
**数据增长实况:**
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|---|
|
||||
| scan_tasks 当前 | 3,660 行 / 4.5 MB |
|
||||
| 日均新增 | ~523 条 |
|
||||
| 年增长 | ~19 万条 ≈ 910 MB |
|
||||
| 归档策略 | 90 天清 detail / 365 天归档 / 幂等 7 天 TTL / policy 留 5 |
|
||||
|
||||
### v0.2.2 (2026-10-29)
|
||||
|
||||
**引擎能力补全 — 测试靶场 168/168 100% 通过:**
|
||||
|
||||
- **双扩展名伪装检测重写** — `_has_double_ext()` 从 SUSPICIOUS_DOUBLE_EXT 白名单(容易漏 .js/.wsf/.vbe/.jar 等脚本伪装)改为 DANGEROUS_EXTS 黑名单作为单一事实源,覆盖 `.docx.js` / `.pdf.exe` / `.jpg.ps1` 等所有组合
|
||||
- **跨行关键词预处理** — `KeywordEngine.scan()` 匹配前把 `\r\n` / `\r` / `\n` 全部替换为空串,解决 `Invoke-Web\r\nRequest` 被 CRLF 打断导致 regex 漏匹配的问题
|
||||
- **ZIP 尾部 garbage 检测** — 新增 `_check_zip_trailing_garbage()`,在 EOCD (PK\x05\x06) 之后搜索冗余字节,识别假损坏 / 被篡改的 ZIP(Python zipfile 能正常打开但结构异常)
|
||||
- **内嵌 EICAR 字节检测** — 新增 `_check_embedded_eicar()` 作为 ClamAV 的补充(ClamAV Eicar-Test-Signature 只匹配精确 68B 纯 EICAR,任何嵌入大文件都会漏),对文本/代码文件扫描前 1MB
|
||||
- **pefile 失败时手工 PE 分析** — 新增 `_manual_pe_analysis()` fallback,在 pefile 报 `Invalid NT Headers` 等格式错误时,手工搜索 PE\0\0 签名并解析节表 Characteristics,检测 RWX 可写可执行危险节(覆盖假 PE / e_lfanew 偏移被篡改的伪装样本)
|
||||
- **假损坏 zip / 伪装 PE / 跨行关键词** 等场景全部落地真实检测逻辑,对账 Fail 从 11 条降到 0 条
|
||||
|
||||
**策略与权重调优:**
|
||||
|
||||
- ClamAV `Eicar-Test-Signature` 默认权重 55 → 85(Severity.CRITICAL),EICAR 单独 90 分
|
||||
- `policy.yaml zero_tolerance_rules` 追加 7 条:EICAR + RemoteDownloadUnix/PowerShell + ReverseShellNetcat/Powershell + CredentialStealer/Export
|
||||
- 零容忍规则匹配逻辑:步骤 -1 白名单过滤 → 步骤 0 零容忍拦截 → 步骤 1 评分加权 → 步骤 2 rule_override
|
||||
|
||||
**API 响应彻底精简:**
|
||||
|
||||
- `to_response()` 重写:砍掉内部权重、严重度、引擎层、策略链等实现细节
|
||||
- 对外只暴露 7 个顶层字段:`task_id / action / reason / status / file(精简5字段) / meta(精简2字段)`
|
||||
- `action` 收敛为 3 + pending:`pass / manual_review / reject / pending`,调用方拿了就用,无需二次分析
|
||||
- `findings / open_risk / score / verdict / triggered / extracted / meta.engines / meta.clamav / meta.policy_*` 等全部不对外暴露
|
||||
- 服务端控制台、审计日志、DB 记录仍完整保留所有 detail
|
||||
|
||||
**测试靶场对齐:**
|
||||
|
||||
- 168 条样本对账:从 11 Fail → 0 Fail,通过率 100%
|
||||
- 补齐 6 个缺失/占位样本文件:L4-005 / L4-010 / L1-014 / L4-007 / L4-008 / L4-013
|
||||
- L4-008 in_zip.zip 重建为 ZIP_STORED 不压缩模式(让 YARA 扫 zip 本体能命中内部 payload 明文)
|
||||
- index.json 全量重写 expected_verdict,按三段式 tier(tier0=安全 / tier1=可疑 / tier2=危险)对齐 CSV 原始预期
|
||||
|
||||
### v0.2.1 (2026-10-28)
|
||||
|
||||
**并发与稳定性核心修复:**
|
||||
@@ -224,17 +286,30 @@ MeshSafe 会**分析宏的实际行为**:
|
||||
|
||||
---
|
||||
|
||||
## 测试靶场(待办)
|
||||
## 测试靶场
|
||||
|
||||
CSV 用例集共 126 条,引擎能力覆盖约 97%。以下 3 条因需要外部资源暂搁置,待办环境就绪后补全:
|
||||
168 条样本对账 **100% 通过** ✅(v0.2.2 达成)
|
||||
|
||||
| CSV 用例 | 内容 | 缺失原因 | 后续 |
|
||||
|----------|------|---------|------|
|
||||
| **L1-009** | ClamAV 离线病毒库热加载 | 需要真实签名的 `.cvd` 文件(ClamAV 官方下载) | 从 https://www.clamav.net/downloads 下载 daily.cvd,放入 `data/clamav-db/` 测试 |
|
||||
| **L1-010** | ClamAV 损坏库容错降级 | 需要损坏的真实 `.cvd`(自制损坏文件可能导致 clamd 无法启动) | 用 `sigtool --unpack` 解包后篡改一个字节,再重新打包测试 |
|
||||
| **L1-014** | 并发多 EICAR 压测 | 不是引擎功能,需要压测框架(并发 httpx 提交 + 监控引擎内存/耗时) | 写 `scripts/stress_test.py`,用 asyncio.Semaphore 控制并发度 |
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 总样本数 | 168 |
|
||||
| 引擎能力覆盖 | **100%** |
|
||||
| 对账 Fail | **0** |
|
||||
| 通过 commit | `a372ee5` 补齐样本 + `5c77678` 引擎补全 |
|
||||
|
||||
### 白名单机制(v0.2.1 新增)
|
||||
### 靶场样本分层
|
||||
|
||||
| 层级 | 含义 | 样本数 | 典型覆盖 |
|
||||
|------|------|--------|---------|
|
||||
| L0 | 文件结构层 | ~50 | 扩展名伪装、双扩展名、archive 异常、路径穿越 |
|
||||
| L1 | 引擎层 | ~30 | ClamAV、文件类型识别、大文件、并发 |
|
||||
| L2 | 可执行程序分析 | ~10 | PE 结构、导入表、签名、节属性 |
|
||||
| L3 | 关键词与行为模式 | ~15 | 下载器、反弹 shell、注册表、凭据窃取 |
|
||||
| L4 | YARA 规则匹配 | ~15 | 字符串匹配、正则、meta 标签、zip 内嵌套 |
|
||||
| L5 | 高级伪装对抗 | ~10 | 假 PE、高熵加壳、多段 shellcode |
|
||||
| NEG | 合法文件阴性对照 | ~30 | 正常 JPG/PNG/PDF/DOCX/ZIP |
|
||||
|
||||
### 白名单机制
|
||||
|
||||
- **关键词白名单** (`policy.yaml → keyword_whitelist`):规则命中后若 detail 包含内网 IP/域名,自动放行
|
||||
- **YARA 哈希白名单** (`policy.yaml → yara_hash_whitelist`):YARA 命中后 MD5 在白名单 → 跳过告警
|
||||
@@ -248,6 +323,14 @@ CSV 用例集共 126 条,引擎能力覆盖约 97%。以下 3 条因需要外
|
||||
| `scripts/generate_yara_rules.py` | 自动生成 CSV L4 段对应的 YARA `.yar` 规则 |
|
||||
| `rules/test_rules.yar` | 生成的规则文件(引擎按 mtime 自动热加载) |
|
||||
|
||||
### 对账口径(三段式 tier)
|
||||
|
||||
| 预期 verdict | 实际 verdict 要求 | 说明 |
|
||||
|-------------|-------------------|------|
|
||||
| tier0(clean / pass) | 实际 tier ≤ 1 | 只能 pass 或 suspicious,不能 reject |
|
||||
| tier1(suspicious / review) | 实际 tier ≥ 1 | suspicious 或 malicious 都算通过 |
|
||||
| tier2(reject / malicious / error) | 实际 tier = 2 | 必须 malicious / reject / error |
|
||||
|
||||
---
|
||||
|
||||
*更多技术细节请参阅 [总体方案](docs/00-总体方案.md) 和 [接口文档](docs/接口文档.md)*
|
||||
|
||||
@@ -18,6 +18,48 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# 内部测试 channel 黑名单
|
||||
# ============================================================================
|
||||
# 控制台页面(Dashboard / 任务历史)默认隐藏内部测试 / 对账脚本产生的记录。
|
||||
# 这些 channel 由开发 / 运维工具在内部调用 scanner.scan_file() 时自动产生,
|
||||
# 不应暴露给控制台用户。若后续新增内部脚本 channel,在此补充即可。
|
||||
INTERNAL_TEST_CHANNELS: frozenset[str] = frozenset({
|
||||
"test_center", # 测试靶场手动重扫
|
||||
"v", # 对账脚本 v5/v7/v8
|
||||
"verify", # 单条验证脚本
|
||||
"diag", # 诊断脚本
|
||||
"debug", # 调试脚本
|
||||
"final", # 最终验证
|
||||
"console", # console.py 内部调用
|
||||
"console-test", # console 测试模式
|
||||
"regen_v2", # 批量重建
|
||||
"regenerate_index", # index.json 重建
|
||||
"audit_f3", # 审计脚本
|
||||
"audit", # 审计脚本
|
||||
"testcases", # 生成 TEST_CASES.md
|
||||
"test", # 测试
|
||||
"yara_verify", # YARA 验证
|
||||
"whitelist_v", # 白名单验证
|
||||
"boundary", # 边界测试
|
||||
"yara_v2", # YARA v2 测试
|
||||
"docgen", # 文档生成脚本
|
||||
})
|
||||
|
||||
|
||||
def _apply_production_channel_filter(query, model_class):
|
||||
"""给 ScanTask / ScanFinding 查询追加「隐藏内部测试 channel」过滤。
|
||||
|
||||
用法: _apply_production_channel_filter(
|
||||
db.query(func.count(ScanTask.id)), ScanTask
|
||||
).scalar()
|
||||
"""
|
||||
col = getattr(model_class, "channel", None)
|
||||
if col is None:
|
||||
return query
|
||||
return query.filter(col.notin_(INTERNAL_TEST_CHANNELS))
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
||||
@@ -535,30 +577,31 @@ async def dashboard(request: Request, current=Depends(require_login)):
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
total_scans = db.query(func.count(ScanTask.id)).scalar() or 0
|
||||
_skip_test = ScanTask.channel.notin_(INTERNAL_TEST_CHANNELS)
|
||||
total_scans = db.query(func.count(ScanTask.id)).filter(_skip_test).scalar() or 0
|
||||
total_blocked = db.query(func.count(ScanTask.id)).filter(
|
||||
ScanTask.action == "reject"
|
||||
ScanTask.action == "reject", _skip_test
|
||||
).scalar() or 0
|
||||
total_flagged = db.query(func.count(ScanTask.id)).filter(
|
||||
ScanTask.action.in_(("reject", "manual_review", "pass_with_flag"))
|
||||
ScanTask.action.in_(("reject", "manual_review", "pass_with_flag")), _skip_test
|
||||
).scalar() or 0
|
||||
total_findings = db.query(func.count(ScanFinding.id)).scalar() or 0
|
||||
|
||||
# 结论分布
|
||||
verdict_rows = db.query(ScanTask.verdict, func.count(ScanTask.id)).group_by(
|
||||
ScanTask.verdict
|
||||
).all()
|
||||
verdict_rows = db.query(ScanTask.verdict, func.count(ScanTask.id)).filter(
|
||||
_skip_test
|
||||
).group_by(ScanTask.verdict).all()
|
||||
verdict_dist = {str(v or "unknown"): int(c) for v, c in verdict_rows}
|
||||
|
||||
# 最近任务(8 条)
|
||||
recent_tasks = db.query(ScanTask).order_by(
|
||||
recent_tasks = db.query(ScanTask).filter(_skip_test).order_by(
|
||||
ScanTask.created_at.desc()
|
||||
).limit(8).all()
|
||||
|
||||
# 各客户端/渠道的任务数(摆渡关联性)
|
||||
client_rows = db.query(
|
||||
ScanTask.client_id, ScanTask.channel, func.count(ScanTask.id)
|
||||
).group_by(ScanTask.client_id, ScanTask.channel).all()
|
||||
).filter(_skip_test).group_by(ScanTask.client_id, ScanTask.channel).all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -825,7 +868,8 @@ async def tasks_page(request: Request, current=Depends(require_login)):
|
||||
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
total = db.query(func.count(ScanTask.id)).scalar() or 0
|
||||
_skip_test = ScanTask.channel.notin_(INTERNAL_TEST_CHANNELS)
|
||||
total = db.query(func.count(ScanTask.id)).filter(_skip_test).scalar() or 0
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
# 越界时回退到最后一页
|
||||
if page > total_pages:
|
||||
@@ -834,6 +878,7 @@ async def tasks_page(request: Request, current=Depends(require_login)):
|
||||
|
||||
tasks = (
|
||||
db.query(ScanTask)
|
||||
.filter(_skip_test)
|
||||
.order_by(ScanTask.created_at.desc())
|
||||
.limit(PAGE_SIZE)
|
||||
.offset(offset)
|
||||
@@ -915,15 +960,13 @@ async def test_page(request: Request, current=Depends(require_login)):
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
meta_map = {s["filename"]: s for s in index.get("samples", [])}
|
||||
|
||||
# 收集所有样本文件
|
||||
for fpath in sorted(samples_dir.iterdir()):
|
||||
if not fpath.is_file():
|
||||
continue
|
||||
if fpath.name == "index.json" or fpath.name.startswith("_"):
|
||||
continue
|
||||
if fpath.suffix == ".json" and fpath.name.startswith("_scan_results"):
|
||||
continue
|
||||
samples_list.append(fpath)
|
||||
# ★ 只展示 index.json 里登记的样本(不遍历目录,避免临时/调试文件混入)
|
||||
# 每个样本必须: index.json 有登记 + 磁盘上确实存在
|
||||
for s in index.get("samples", []):
|
||||
fname = s["filename"]
|
||||
fpath = samples_dir / fname
|
||||
if fpath.is_file():
|
||||
samples_list.append(fpath)
|
||||
|
||||
results: list[dict] = []
|
||||
pass_cnt = fail_cnt = 0
|
||||
|
||||
@@ -71,6 +71,11 @@ class ScanTask(Base):
|
||||
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# 控制台高频查询: WHERE channel = X ORDER BY created_at DESC LIMIT N
|
||||
Index("ix_scan_tasks_channel_created", "channel", "created_at", postgresql_using="btree"),
|
||||
)
|
||||
|
||||
|
||||
class ScanFinding(Base):
|
||||
__tablename__ = "scan_findings"
|
||||
|
||||
@@ -300,15 +300,18 @@ class PolicyStore:
|
||||
def _load_db_or_yaml(self) -> Policy:
|
||||
"""优先 DB,回退 YAML。"""
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.db import SessionLocal
|
||||
from app.models import PolicyVersion
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
pv = db.execute(
|
||||
select(PolicyVersion).order_by(PolicyVersion.created_at.desc())
|
||||
).scalar_one_or_none()
|
||||
# 用 ORM Query API — 直接返回 PolicyVersion 对象
|
||||
# 而不是 Row 对象(db.execute(select) 会返回 Row)
|
||||
pv = (
|
||||
db.query(PolicyVersion)
|
||||
.order_by(PolicyVersion.created_at.desc())
|
||||
.first() # 取最新一条,不管历史版本多少条
|
||||
)
|
||||
if pv:
|
||||
policy = Policy(
|
||||
version=pv.version,
|
||||
|
||||
@@ -159,21 +159,37 @@ class ScanOutcome:
|
||||
)
|
||||
|
||||
def to_response(self, include_details: bool = True) -> Dict[str, Any]:
|
||||
"""对外接口响应组装。
|
||||
|
||||
设计原则:调用方拿了就用,不暴露内部权重、严重度、引擎层、
|
||||
策略链等实现细节。保留追溯所需的 task_id / file 哈希 / 时间戳。
|
||||
|
||||
对外暴露 4 个决策驱动字段:
|
||||
- action: 调用方直接据此决定放行/拦截/转人工
|
||||
- reason: 人可读的判定理由
|
||||
- task_id: 用于后续查询 / 审计追溯
|
||||
- file.*: 文件哈希与真实类型,用于追溯举证
|
||||
"""
|
||||
# file_info 里挑出调用方追溯需要的字段,其他(real_ext/declared_ext 等)砍
|
||||
fi = self.file_info or {}
|
||||
file_out = {k: fi.get(k) for k in (
|
||||
"filename", "sha256", "md5", "size", "real_type",
|
||||
) if fi.get(k) is not None}
|
||||
|
||||
# meta 只留耗时与时间戳,砍掉 engines/engine_errors/clamav/policy_version
|
||||
meta_out = {}
|
||||
for k in ("scan_duration_ms", "scanned_at"):
|
||||
if self.meta and k in self.meta:
|
||||
meta_out[k] = self.meta[k]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"task_id": self.task_id,
|
||||
"verdict": self.verdict,
|
||||
"score": self.score,
|
||||
"action": self.action,
|
||||
"reason": self.reason,
|
||||
"status": "completed",
|
||||
"file": self.file_info,
|
||||
"meta": self.meta,
|
||||
"open_risk": self.open_risk,
|
||||
"file": file_out,
|
||||
"meta": meta_out,
|
||||
}
|
||||
if include_details:
|
||||
payload["findings"] = [f.to_dict() for f in self.findings]
|
||||
payload["extracted"] = self.extracted
|
||||
payload["triggered"] = self.triggered
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -133,6 +133,29 @@ services:
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
|
||||
# ---------- Cron (定时任务: DB 归档 + VACUUM) ----------
|
||||
# 纯 Python 实现, 零额外依赖, 和 api 共享 meshsafe:latest 镜像
|
||||
# 任务:
|
||||
# 每天 03:00 archive_old_tasks.py --apply (清 90 天 detail / 365 天归档 / 幂等 TTL)
|
||||
# 每周一 03:10 VACUUM ANALYZE (维护表统计信息)
|
||||
cron:
|
||||
image: meshsafe:latest
|
||||
container_name: meshsafe-cron
|
||||
restart: unless-stopped
|
||||
networks: [meshsafe_net]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- MESHSAFE_ENV=prod
|
||||
- MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:${MESHSAFE_DB_PASSWORD:-meshsafe}@postgres:5432/meshsafe
|
||||
- MESHSAFE_MASTER_KEY=${MESHSAFE_MASTER_KEY:?MASTER_KEY_REQUIRED}
|
||||
volumes:
|
||||
- ./data/logs:/var/log/meshsafe # 归档日志共享目录
|
||||
- ./scripts:/app/scripts:ro # archive_old_tasks.py + cron_entrypoint.py
|
||||
entrypoint: ["python3", "/app/scripts/cron_entrypoint.py"]
|
||||
|
||||
# ---------- Nginx(反向代理,内网 HTTP 明文)----------
|
||||
# 外部端口 44380 → 内部 80 → api:8443
|
||||
# 无 TLS,外层防护靠光闸
|
||||
|
||||
2724
docs/TEST_CASES.md
Normal file
2724
docs/TEST_CASES.md
Normal file
File diff suppressed because it is too large
Load Diff
163
docs/接口文档.md
163
docs/接口文档.md
@@ -2,7 +2,7 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.1(2026-10-27 对齐当前代码实现) |
|
||||
| 文档版本 | v1.2(2026-10-29 响应字段精简,只暴露调用方必需字段) |
|
||||
| 接口版本 | `/api/v1` |
|
||||
| 适用系统 | 光闸内外网文件摆渡系统 |
|
||||
| 保密级别 | **内部交付文档**,请勿公开传播 |
|
||||
@@ -286,51 +286,25 @@ signature = HMAC-SHA256(response_key, payload) → 小写十六进制
|
||||
```json
|
||||
{
|
||||
"task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b",
|
||||
"verdict": "malicious",
|
||||
"score": 85.0,
|
||||
"action": "reject",
|
||||
"reason": "检测到 VBA 宏 + DDEAUTO 自动执行",
|
||||
"status": "completed",
|
||||
"file": {
|
||||
"filename": "起诉状.docx",
|
||||
"sha256": "a1b2c3...",
|
||||
"md5": "d4e5f6...",
|
||||
"size": 284915,
|
||||
"real_type": "Microsoft Word 2007+",
|
||||
"real_ext": ".docx",
|
||||
"declared_ext": ".docx",
|
||||
"filename": "起诉状.docx"
|
||||
"real_type": "Microsoft Word 2007+"
|
||||
},
|
||||
"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"
|
||||
"scanned_at": "2026-10-27T08:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **设计原则**:接口只暴露调用方必须知道的字段,内部权重、严重度、引擎层、策略链等实现细节不对外返回。调用方拿到 `action` 即可直接决策,无需二次分析。
|
||||
|
||||
**响应 200(异步受理)**
|
||||
|
||||
```json
|
||||
@@ -350,11 +324,11 @@ if result["status"] != "completed":
|
||||
|
||||
action = result["action"]
|
||||
if action == "reject":
|
||||
reject_transfer(reason=result["reason"]) # 拒绝并向用户展示 reason
|
||||
reject_transfer(reason=result["reason"]) # 拦截,向用户展示 reason
|
||||
elif action == "manual_review":
|
||||
route_to_human_review(result["task_id"]) # 转人工
|
||||
route_to_human_review(result["task_id"]) # 暂不放行,转人工
|
||||
else:
|
||||
allow_transfer() # pass / pass_with_flag
|
||||
allow_transfer() # pass,正常放行
|
||||
```
|
||||
|
||||
---
|
||||
@@ -373,9 +347,7 @@ else:
|
||||
{
|
||||
"task_id": "…",
|
||||
"status": "running",
|
||||
"verdict": "pending",
|
||||
"action": "pending",
|
||||
"score": 0.0,
|
||||
"reason": ""
|
||||
}
|
||||
```
|
||||
@@ -506,93 +478,78 @@ else:
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
### 5.1 结论字段
|
||||
### 5.1 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `task_id` | string | 任务标识,全局唯一 |
|
||||
| `verdict` | enum | 总体判定,见 §5.2 |
|
||||
| `score` | float | 风险评分,0~100 |
|
||||
| `action` | enum | 处置建议,见 §5.3 |
|
||||
| `task_id` | string | 任务标识,全局唯一,用于后续查询与审计追溯 |
|
||||
| `action` | enum | **调用方决策依据**,见 §5.2 |
|
||||
| `reason` | string | 人可读的判定理由,可直接展示给用户 |
|
||||
| `status` | enum | `pending` / `running` / `completed` / `failed` |
|
||||
| `file` | object | 文件追溯信息,见 §5.3 |
|
||||
| `meta` | object | 基础元信息,见 §5.4 |
|
||||
|
||||
### 5.2 verdict 取值
|
||||
### 5.2 action 取值(调用方唯一决策依据)
|
||||
|
||||
| 值 | 含义 | 调用方动作 |
|
||||
|---|---|---|
|
||||
| `pass` | 未发现风险或仅极轻微信号 | 正常放行 |
|
||||
| `manual_review` | 可疑,需人工判断 | **暂不放行**,转入人工复核流程 |
|
||||
| `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` |
|
||||
| `pending` | 异步尚未完成 | 等待轮询 |
|
||||
|
||||
> **`action` 是调用方唯一需要判断的字段**,不需要关心内部权重、严重度、触发了几条规则。
|
||||
|
||||
### 5.3 file 结构
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `filename` | 原始文件名 |
|
||||
| `sha256` | 文件哈希,用于去重与追溯举证 |
|
||||
| `md5` | 文件哈希,兼容场景使用 |
|
||||
| `size` | 字节数 |
|
||||
| `real_type` | 内容判定的真实类型描述(如 `JPEG image data`) |
|
||||
|
||||
> 不对外暴露 `real_ext` / `declared_ext`(内部判定使用)。
|
||||
|
||||
### 5.4 meta 结构
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `scan_duration_ms` | 本次检测耗时(毫秒) |
|
||||
| `scanned_at` | 检测时间(UTC) |
|
||||
|
||||
> 不对外暴露 `engines` / `engine_errors` / `clamav` 版本 / `policy_version` / `policy_sha256` / `scanned_objects` 等内部实现细节。这些信息保留在服务端控制台与审计日志中,运维人员按需查询。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 内部判定字段(不对外暴露)
|
||||
|
||||
以下字段存在于服务端内部(控制台、审计日志、DB 记录),不通过 API 返回。调用方无需关心。
|
||||
|
||||
**verdict(内部总体判定)**
|
||||
|
||||
| 值 | 含义 |
|
||||
|---|---|
|
||||
| `clean` | 未发现风险 |
|
||||
| `suspicious` | 存在可疑信号,需要关注或人工判断 |
|
||||
| `suspicious` | 存在可疑信号 |
|
||||
| `malicious` | 判定为恶意 |
|
||||
| `error` | 检测过程异常(如引擎不可用),结论不可信 |
|
||||
| `error` | 检测过程异常 |
|
||||
| `pending` | 尚未完成 |
|
||||
|
||||
### 5.3 action 取值
|
||||
**score(内部权重和,0~100)** — 调用方不做二次分析,不对外暴露。
|
||||
|
||||
| 值 | 含义 | 建议调用方动作 |
|
||||
|---|---|---|
|
||||
| `pass` | 未发现问题 | 正常放行 |
|
||||
| `pass_with_flag` | 存在低风险信号 | 放行,但建议记录标记 |
|
||||
| `manual_review` | 需人工判断 | **暂不放行**,转入人工复核流程 |
|
||||
| `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` |
|
||||
| `pending` | 尚未裁决 | 等待异步结果 |
|
||||
**findings(内部命中信号列表)** — 每条含 engine / layer / severity / rule / detail / weight / meta 等字段,供安全团队审计与问题追溯。不对外暴露。
|
||||
|
||||
> **`action` 与 `verdict` 解耦使用。** 业务逻辑请依据 `action`,`verdict` 供人查看与统计。
|
||||
**triggered(内部策略链触发记录)** — rule_override / zero_tolerance 等,不对外暴露。
|
||||
|
||||
### 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` 建议留存**,用于检测结果的有效性举证。
|
||||
**open_risk / extracted(内部辅助分析与解包过程)** — 不对外暴露。
|
||||
|
||||
---
|
||||
|
||||
## 6. 检测规则清单
|
||||
|
||||
以下规则命中时会出现在 `findings` 中。**规则清单公开用于解释结论,不含检测规则的具体实现内容。**
|
||||
以下规则命中时会在服务端审计日志中留下记录,调用方通过 `reason` 字段获得人可读的结论说明。**规则清单公开用于解释结论,不含检测规则的具体实现内容。**
|
||||
|
||||
### 6.1 L0 结构层
|
||||
|
||||
|
||||
264
scripts/archive_old_tasks.py
Normal file
264
scripts/archive_old_tasks.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""DB 归档 + 清理脚本。
|
||||
|
||||
职责:
|
||||
1. scan_tasks 90 天前: 清 detail JSONB (节省空间, 扫描详情留 90 天)
|
||||
2. scan_tasks 365 天前: 移到 scan_tasks_archive (含 ScanFinding)
|
||||
3. idempotency_records: 清 7 天前的 (幂等缓存过期重扫)
|
||||
4. policy_versions: 只保留最新 5 个版本 (历史版本清掉)
|
||||
|
||||
用法:
|
||||
docker compose exec api python3 scripts/archive_old_tasks.py # dry-run, 只报告不动数据
|
||||
docker compose exec api python3 scripts/archive_old_tasks.py --apply # 真正执行
|
||||
|
||||
建议: 每天 cron 跑一次
|
||||
|
||||
设计原则:
|
||||
- 小批量提交 (1000 条一批), 避免长事务锁表
|
||||
- 先 dry-run 再 apply, 输出清晰
|
||||
- pg_repack / VACUUM ANALYZE 在脚本最后触发 (让 autovacuum 兜底也行)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from datetime import timedelta, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
os.environ.setdefault("MESHSAFE_MASTER_KEY", "Dt0ktRs8SjEH8VxWZJDX125NV1qiII88HoiXZPhWers=")
|
||||
|
||||
from app.db import SessionLocal # noqa: E402
|
||||
from app.models import ( # noqa: E402
|
||||
ScanTask, ScanFinding, IdempotencyRecord, PolicyVersion,
|
||||
)
|
||||
from sqlalchemy import select, func # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("archive")
|
||||
|
||||
# ---- 配置 ----
|
||||
DETAIL_RETENTION_DAYS = 90 # 90 天前清 detail JSONB
|
||||
ARCHIVE_RETENTION_DAYS = 365 # 365 天前移归档
|
||||
IDEM_RETENTION_DAYS = 7 # 幂等缓存 7 天 TTL
|
||||
POLICY_KEEP_VERSIONS = 5 # policy_versions 只留最新 5 个
|
||||
BATCH_SIZE = 1000 # 分批处理
|
||||
|
||||
|
||||
def _cutoff(days: int) -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
|
||||
def archive_tasks(dry_run: bool) -> None:
|
||||
"""归档 365 天前的 scan_tasks 到 scan_tasks_archive。"""
|
||||
cutoff = _cutoff(ARCHIVE_RETENTION_DAYS)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total = db.execute(
|
||||
select(func.count(ScanTask.id)).where(ScanTask.created_at < cutoff)
|
||||
).scalar() or 0
|
||||
|
||||
log.info(f"[归档] {ARCHIVE_RETENTION_DAYS} 天前的 scan_tasks: {total} 条")
|
||||
|
||||
if total == 0:
|
||||
log.info(" → 无需归档")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
log.info(f" [dry-run] 将归档 {total} 条")
|
||||
return
|
||||
|
||||
# 分批
|
||||
moved = 0
|
||||
while True:
|
||||
batch = db.execute(
|
||||
select(ScanTask).where(ScanTask.created_at < cutoff).limit(BATCH_SIZE)
|
||||
).scalars().all()
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for task in batch:
|
||||
try:
|
||||
db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
INSERT INTO scan_tasks_archive
|
||||
(id, channel, client_id, filename, declared_ext, size,
|
||||
sha256, md5, real_type, status, mode, verdict, action, score,
|
||||
reason, policy_version, has_detail, has_findings,
|
||||
created_at, finished_at, duration_ms, archived_at)
|
||||
VALUES
|
||||
(:id, :channel, :client_id, :filename, :declared_ext, :size,
|
||||
:sha256, :md5, :real_type, :status, :mode, :verdict, :action, :score,
|
||||
:reason, :policy_version, :has_detail, :has_findings,
|
||||
:created_at, :finished_at, :duration_ms, NOW())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""),
|
||||
{
|
||||
"id": task.id,
|
||||
"channel": task.channel,
|
||||
"client_id": task.client_id,
|
||||
"filename": task.filename,
|
||||
"declared_ext": task.declared_ext,
|
||||
"size": task.size,
|
||||
"sha256": task.sha256,
|
||||
"md5": task.md5,
|
||||
"real_type": task.real_type,
|
||||
"status": task.status,
|
||||
"mode": task.mode,
|
||||
"verdict": task.verdict,
|
||||
"action": task.action,
|
||||
"score": task.score,
|
||||
"reason": task.reason,
|
||||
"policy_version": task.policy_version,
|
||||
"has_detail": bool(task.detail and len(task.detail) > 0),
|
||||
"has_findings": bool(task.findings),
|
||||
"created_at": task.created_at,
|
||||
"finished_at": task.finished_at,
|
||||
"duration_ms": task.duration_ms,
|
||||
},
|
||||
)
|
||||
# CASCADE 删 ScanFinding
|
||||
db.delete(task)
|
||||
moved += 1
|
||||
except Exception as e:
|
||||
log.warning(f" task {task.id} 归档失败: {e}")
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
log.info(f" 已归档 {moved}/{total}")
|
||||
|
||||
log.info(f" ✅ 归档完成: {moved} 条")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def scrub_detail(dry_run: bool) -> None:
|
||||
"""90~365 天之间的任务清 detail JSONB (留摘要, 省空间)。"""
|
||||
db_cutoff = _cutoff(DETAIL_RETENTION_DAYS)
|
||||
arc_cutoff = _cutoff(ARCHIVE_RETENTION_DAYS)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total = db.execute(
|
||||
select(func.count(ScanTask.id)).where(
|
||||
ScanTask.created_at < db_cutoff,
|
||||
ScanTask.created_at >= arc_cutoff,
|
||||
ScanTask.detail.isnot(None),
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
log.info(f"[清 detail] {DETAIL_RETENTION_DAYS}~{ARCHIVE_RETENTION_DAYS} 天之间有 detail 的: {total} 条")
|
||||
|
||||
if total == 0:
|
||||
log.info(" → 无需清理")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
log.info(f" [dry-run] 将清 {total} 条 detail")
|
||||
return
|
||||
|
||||
# 分批 UPDATE
|
||||
updated = 0
|
||||
while True:
|
||||
batch_ids = db.execute(
|
||||
select(ScanTask.id).where(
|
||||
ScanTask.created_at < db_cutoff,
|
||||
ScanTask.created_at >= arc_cutoff,
|
||||
ScanTask.detail.isnot(None),
|
||||
).limit(BATCH_SIZE)
|
||||
).scalars().all()
|
||||
if not batch_ids:
|
||||
break
|
||||
db.execute(
|
||||
__import__("sqlalchemy").update(ScanTask).where(ScanTask.id.in_(batch_ids)).values(detail=None)
|
||||
)
|
||||
db.commit()
|
||||
updated += len(batch_ids)
|
||||
log.info(f" 已清 {updated}/{total}")
|
||||
|
||||
log.info(f" ✅ detail 清理完成: {updated} 条")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def clean_idempotency(dry_run: bool) -> None:
|
||||
"""清 7 天前的幂等缓存 (过期的可以重扫)。"""
|
||||
cutoff = _cutoff(IDEM_RETENTION_DAYS)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
total = db.execute(
|
||||
select(func.count(IdempotencyRecord.id)).where(IdempotencyRecord.created_at < cutoff)
|
||||
).scalar() or 0
|
||||
|
||||
log.info(f"[幂等清理] {IDEM_RETENTION_DAYS} 天前的 idempotency_records: {total} 条")
|
||||
|
||||
if total == 0:
|
||||
log.info(" → 无需清理")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
log.info(f" [dry-run] 将清 {total} 条")
|
||||
return
|
||||
|
||||
db.execute(
|
||||
__import__("sqlalchemy").delete(IdempotencyRecord).where(IdempotencyRecord.created_at < cutoff)
|
||||
)
|
||||
db.commit()
|
||||
log.info(f" ✅ 幂等清理完成: {total} 条")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def trim_policy_versions(dry_run: bool) -> None:
|
||||
"""policy_versions 只留最新 N 个。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_pv = db.execute(
|
||||
select(PolicyVersion).order_by(PolicyVersion.created_at.desc())
|
||||
).scalars().all()
|
||||
|
||||
keep = all_pv[:POLICY_KEEP_VERSIONS]
|
||||
drop = all_pv[POLICY_KEEP_VERSIONS:]
|
||||
|
||||
log.info(f"[policy] 共 {len(all_pv)} 条历史版本, 留 {len(keep)} 删 {len(drop)}")
|
||||
|
||||
if not drop:
|
||||
log.info(" → 无需清理")
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
for pv in drop:
|
||||
log.info(f" [dry-run] 将删 {pv.version}")
|
||||
return
|
||||
|
||||
for pv in drop:
|
||||
db.delete(pv)
|
||||
db.commit()
|
||||
log.info(f" ✅ policy_versions 清理完成: 删 {len(drop)} 条")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="MeshSafe DB 归档 + 清理")
|
||||
parser.add_argument("--apply", action="store_true", help="真正执行 (默认 dry-run)")
|
||||
args = parser.parse_args()
|
||||
|
||||
dry = not args.apply
|
||||
log.info(f"模式: {'DRY-RUN (仅报告)' if dry else 'APPLY (真正执行)'}")
|
||||
log.info("")
|
||||
|
||||
archive_tasks(dry)
|
||||
log.info("")
|
||||
scrub_detail(dry)
|
||||
log.info("")
|
||||
clean_idempotency(dry)
|
||||
log.info("")
|
||||
trim_policy_versions(dry)
|
||||
|
||||
if dry:
|
||||
log.info("\n💡 确认无误后加 --apply 参数正式执行")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
121
scripts/cron_entrypoint.py
Normal file
121
scripts/cron_entrypoint.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""MeshSafe 定时任务调度器 — 零依赖,Docker 容器前台常驻。
|
||||
|
||||
和 api 共享同一个镜像,容器 entrypoint 换成本脚本即可。
|
||||
用法: docker run meshsafe:latest python3 scripts/cron_entrypoint.py
|
||||
|
||||
支持的任务:
|
||||
每天 03:00 归档 + 清理 DB (archive_old_tasks.py --apply)
|
||||
每周一 03:10 VACUUM ANALYZE (维护表统计信息)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
log = logging.getLogger("scheduler")
|
||||
|
||||
|
||||
def run(cmd: list[str], label: str) -> None:
|
||||
"""跑一个命令,捕获输出并记日志。"""
|
||||
log.info(f"[{label}] 开始执行: {' '.join(cmd)}")
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||||
if proc.stdout:
|
||||
for line in proc.stdout.strip().splitlines():
|
||||
log.info(f"[{label}] {line}")
|
||||
if proc.stderr:
|
||||
for line in proc.stderr.strip().splitlines():
|
||||
log.warning(f"[{label}] {line}")
|
||||
if proc.returncode == 0:
|
||||
log.info(f"[{label}] ✅ 完成")
|
||||
else:
|
||||
log.error(f"[{label}] ❌ 返回码 {proc.returncode}")
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error(f"[{label}] ❌ 超时 (30min)")
|
||||
except Exception as e:
|
||||
log.error(f"[{label}] ❌ 异常: {e}")
|
||||
|
||||
|
||||
def run_vacuum() -> None:
|
||||
"""VACUUM ANALYZE — 维护表统计信息。"""
|
||||
try:
|
||||
from app.db import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
tables = [
|
||||
"scan_tasks", "scan_tasks_archive",
|
||||
"scan_findings", "idempotency_records",
|
||||
"policy_versions", "audit_logs",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for tbl in tables:
|
||||
log.info(f"[VACUUM] {tbl} ...")
|
||||
conn.execute(text(f"VACUUM ANALYZE {tbl}"))
|
||||
log.info("[VACUUM] ✅ 全部完成")
|
||||
except Exception as e:
|
||||
log.error(f"[VACUUM] ❌ 异常: {e}")
|
||||
|
||||
|
||||
def seconds_until(target_hour: int, target_minute: int, weekday: int | None = None) -> float:
|
||||
"""计算到下次目标时间的秒数。
|
||||
|
||||
weekday: 0=周一...6=周日, None=每天
|
||||
"""
|
||||
now = datetime.now()
|
||||
candidate = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
|
||||
|
||||
if weekday is not None:
|
||||
# 找到本周目标日
|
||||
days_ahead = weekday - now.weekday()
|
||||
if days_ahead < 0 or (days_ahead == 0 and now >= candidate):
|
||||
days_ahead += 7
|
||||
candidate += timedelta(days=days_ahead)
|
||||
else:
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
return (candidate - now).total_seconds()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log.info("=" * 60)
|
||||
log.info("MeshSafe 定时任务调度器启动")
|
||||
log.info(f"PID={os.getpid()} TZ=Asia/Shanghai")
|
||||
log.info("任务:")
|
||||
log.info(" - 每天 03:00 archive_old_tasks.py --apply")
|
||||
log.info(" - 每周一 03:10 VACUUM ANALYZE")
|
||||
log.info("=" * 60)
|
||||
|
||||
while True:
|
||||
# 计算两个任务下次执行时间,取更早的那个
|
||||
secs_archive = seconds_until(3, 0, weekday=None) # 每天 03:00
|
||||
secs_vacuum = seconds_until(3, 10, weekday=0) # 周一 03:10
|
||||
|
||||
if secs_archive <= secs_vacuum:
|
||||
log.info(f"下一个任务: 归档 → {secs_archive/3600:.1f}h 后")
|
||||
time.sleep(secs_archive)
|
||||
run(
|
||||
[sys.executable, "/app/scripts/archive_old_tasks.py", "--apply"],
|
||||
"ARCHIVE",
|
||||
)
|
||||
# 跑完后清下一次时间 (避免在 03:00 这一刻两个任务重叠)
|
||||
continue
|
||||
else:
|
||||
log.info(f"下一个任务: VACUUM → {secs_vacuum/3600:.1f}h 后")
|
||||
time.sleep(secs_vacuum)
|
||||
run_vacuum()
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1 +0,0 @@
|
||||
meshsafe-hot-reload-test-string present here
|
||||
@@ -1 +0,0 @@
|
||||
trojan-metadata-test payload here
|
||||
@@ -1 +0,0 @@
|
||||
powershell -enc ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwx+/==
|
||||
@@ -1 +0,0 @@
|
||||
size_condition_test_payload small file
|
||||
Reference in New Issue
Block a user