Files
MeshSate/app/services/policy.py
zhaolei 5d3c895542 fix: policy 加载 bug + DB 索引 + 归档脚本
【Bug 修复】policy.py _load_db_or_yaml
  根因: select(PolicyVersion).order_by(desc).scalar_one_or_none()
        表有多条历史版本 → Multiple rows 异常 → 每次启动都回退 YAML
  修复: 改成 db.query(PolicyVersion).order_by(desc).first()
        用 ORM Query API 取最新一条, 返回真正的 PolicyVersion 对象
  验证: 容器启动后日志中不再出现 'DB 加载策略失败(回退 YAML)'

【索引】models.py ScanTask 加复合索引
  ix_scan_tasks_channel_created ON (channel, created_at DESC)
  解决 dashboard/tasks_page 高频查询的全表扫描问题
  (已在运行中 DB 用 CREATE INDEX CONCURRENTLY 补上)

【归档】scripts/archive_old_tasks.py
  - scan_tasks 90天前 清 detail JSONB (省空间)
  - scan_tasks 365天前 移到 scan_tasks_archive 归档表
  - idempotency_records 7天 TTL 清过期幂等缓存
  - policy_versions 只留最新 5 个
  - 支持 --dry-run (默认) + --apply (真正执行)
  - 分批 1000 条处理, 避免长事务锁表
  - 归档表 scan_tasks_archive 已建 (含 4 个索引)

数据增长: 日均 523 条 → 年 ~19万条 ≈ 910 MB
当前 3660 条 ≈ 4.5 MB, 增长可控, 脚本可每周/每月 cron 跑一次
2026-10-29 17:02:37 +08:00

802 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""全局策略引擎。
职责:
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 keyword_whitelist(self) -> List[Dict[str, Any]]:
"""关键词白名单。规则命中后,若 detail 包含 allowed_keywords 中任一子串 → 过滤。"""
return self.raw.get("keyword_whitelist", []) or []
@property
def yara_hash_whitelist_set(self) -> set[str]:
"""YARA 哈希白名单(返回 MD5 集合,便于快速查询)。"""
raw = self.raw.get("yara_hash_whitelist", []) or []
return {str(entry.get("md5", "")).lower() for entry in raw if isinstance(entry, dict) and entry.get("md5")}
@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 app.db import SessionLocal
from app.models import PolicyVersion
db = SessionLocal()
try:
# 用 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,
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,
md5: str = "",
) -> 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()
# ======================================================================
# 步骤 -1:白名单过滤(优先级最高)
# ======================================================================
# 命中白名单的规则从 findings 中移除,不再参与任何后续裁决。
# 包括:关键词白名单(按 rule + detail 子串匹配)+ YARA 哈希白名单。
# ======================================================================
wl_filtered: List[Finding] = []
keyword_wl = policy.keyword_whitelist
for f in findings:
skip = False
# 关键词白名单:detail 包含 allowed_keywords 任一子串
if keyword_wl and f.engine == "keywords":
for wl_entry in keyword_wl:
if f.rule == wl_entry.get("rule"):
allowed = wl_entry.get("allowed_keywords", [])
if allowed and any(kw in f.detail for kw in allowed):
skip = True
logger.info(
"白名单放行:rule=%s 命中关键词白名单 detail=%s",
f.rule, f.detail,
)
break
# YARA 哈希白名单:md5 在白名单集合中
if not skip and f.engine == "yara":
wl_set = policy.yara_hash_whitelist_set
if wl_set and md5 and md5.lower() in wl_set:
skip = True
logger.info("白名单放行:YARA 命中但 MD5 在哈希白名单")
if not skip:
wl_filtered.append(f)
findings = wl_filtered
# ======================================================================
# 步骤 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"