Files
MeshSate/app/engines/keywords.py
zhaolei 1a0a24e672 fix: L0-011 双扩展名逻辑反转 + L3-009 跨行关键词预处理
- _has_double_ext(): 逻辑从 SUSPICIOUS_DOUBLE_EXT 白名单改为
  DANGEROUS_EXTS 黑名单(单一事实源),以最后一个扩展名
  是危险可执行 + 倒数第二个是安全/文档类作为判定条件
- _check_extension_mismatch(): safe_then_dangerous 硬编码链
  同样用 DANGEROUS_EXTS 替代,同时删掉冗余的局部 import
- KeywordEngine.scan(): 匹配前预处理换行符 (\r\n/\r/\n → 空串)
  解决 Invoke-Web\r\nRequest 被 CRLF 拆分导致关键词漏匹配

对账结果: 159/168 通过 (94.6%),剩余 9 条为真引擎缺失
- L0-026 假损坏 zip、L1-013 大文件内嵌 EICAR、L1-014 占位符
- L4-005/007/008/010/013 YARA 样本占位或规则缺失
- L5-004 假 PE 结构 pefile 解析失败
2026-10-29 15:59:51 +08:00

165 lines
6.1 KiB
Python
Raw 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.
"""内网特征词表引擎(P1)。
数据驱动:词表从 policy.yaml 的 ``keyword_rules`` 段加载,不在代码中硬编码。
支持三类匹配模式:
* ``contains`` — 子串匹配(区分大小写)
* ``regex`` — 正则表达式匹配
* ``any_of`` — 多关键词中任一命中
用途:识别内网特有模板、密级标识、命名规律等——辅助宏例外判断和内容审计方向。
"""
from __future__ import annotations
import logging
import re
from typing import Any, Dict, List
from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
logger = logging.getLogger(__name__)
class KeywordEngine(Engine):
"""内网特征词表匹配引擎。"""
name = "keywords"
layer = "L2B"
version = "1.0"
def __init__(self, keyword_rules: List[Dict[str, Any]] | None = None) -> None:
"""初始化。keyword_rules 来自 PolicyStore.current().keyword_rules。"""
self._rules = keyword_rules or []
# 预编译正则
self._compiled: List[Dict[str, Any]] = []
for rule in self._rules:
pattern = rule.get("pattern", "")
if rule.get("match") == "regex" and pattern:
try:
compiled = re.compile(pattern, re.IGNORECASE if rule.get("ignore_case") else 0)
except re.error:
logger.warning("词表规则正则编译失败: %s", rule.get("name"))
continue
self._compiled.append({**rule, "_re": compiled})
else:
self._compiled.append(rule)
# ------------------------------------------------------------------- 接口实现
def is_enabled(self) -> bool:
return bool(self._rules)
def scan(self, target: ScanTarget, ctx: Any) -> EngineResult:
result = EngineResult(engine=self.name)
if not self._rules:
return result
findings: List[Finding] = []
try:
text = self._extract_text(target.path, target.declared_ext)
except Exception: # noqa: BLE001
return result
if not text:
return result
# ---- 预处理:合并换行符以支持跨行关键词 ----
# 攻击者会把 Invoke-WebRequest 这种关键字用 CRLF 拆成
# "Invoke-Web\r\nRequest" 来绕过基于单行的匹配。
# 这里把所有 \r\n / \r / \n 替换为空,让跨行关键词重新拼接。
# 注意:不影响纯文本/多行语义,我们匹配的都是单行关键词或跨行命令。
text = text.replace("\r\n", "").replace("\r", "").replace("\n", "")
for rule in self._compiled:
match_mode = rule.get("match", "contains")
rule_name = rule.get("name", "keyword")
weight = rule.get("weight", 30.0)
severity_str = rule.get("severity", "medium")
severity = getattr(Severity, severity_str.upper(), Severity.MEDIUM).value
hits: List[str] = []
if match_mode == "regex":
compiled = rule.get("_re")
if compiled:
for m in compiled.finditer(text):
snippet = text[max(0, m.start() - 10): m.end() + 10]
hits.append(snippet.strip())
if len(hits) >= 3:
break
elif match_mode == "any_of":
keywords = rule.get("keywords", [])
for kw in keywords:
if kw.lower() in text.lower():
hits.append(kw)
if len(hits) >= 3:
break
elif match_mode == "contains":
pattern = rule.get("pattern", "")
if pattern and (pattern.lower() in text.lower()):
hits.append(pattern)
if hits:
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=severity,
rule=rule_name,
detail=f"词表命中:{rule_name}(模式={match_mode}),命中 {len(hits)} 处:{hits[:2]}",
weight=float(weight),
)
)
result.findings = findings
result.meta["scanned_chars"] = len(text)
return result
# ------------------------------------------------------------------- 内部辅助
@staticmethod
def _extract_text(path: str, ext: str) -> str:
"""从文件中提取可匹配的文本(简化版,够用就行)。
OOXML/PDF/DOCX 等结构化文档优先交给对应的引擎处理,
词表引擎只扫纯文本、代码文件和可直接读的二进制残留字符串。
"""
text_exts = {".txt", ".csv", ".json", ".xml", ".html", ".htm",
".md", ".log", ".ini", ".cfg", ".yaml", ".yml",
".sh", ".bash", ".zsh", ".py", ".pl", ".rb", ".js", ".ts",
".bat", ".cmd", ".ps1", ".vbs", ".sql", ".go", ".java",
".c", ".cpp", ".h", ".cs"}
ext_lower = (ext or "").lower()
if ext_lower in text_exts:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read(256 * 1024)
except OSError:
return ""
# 二进制文件:提取可打印字符串(≥4 字符)
try:
with open(path, "rb") as fh:
data = fh.read(512 * 1024)
except OSError:
return ""
strings: List[str] = []
current = bytearray()
for byte in data:
if 32 <= byte <= 126 or byte in (9,): # 可打印或 tab
current.append(byte)
else:
if len(current) >= 4:
strings.append(current.decode("ascii", errors="replace"))
current = bytearray()
if len(current) >= 4:
strings.append(current.decode("ascii", errors="replace"))
return "\n".join(strings)