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

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

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

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

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

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

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

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

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