feat: A+B 缺口全补 + README 待办记录

A 结构层小缺口 (3/3):
  ✅ structure.py 加 LongFilename 告警 (>120 字符成员名)
  ✅ extractor.py _SAFE_NAME_RE 扩展 emoji 范围 (保留 emoji 文件名不崩)
  ✅ 重生成 L0-023_5layer_nested.zip (5 层真嵌套)

B 白名单机制 (2/2):
  ✅ policy.yaml 加 keyword_whitelist (内网 IP/域名自动放行 RemoteDownloadUnix/PowerShell)
  ✅ policy.yaml 加 yara_hash_whitelist (MD5 在白名单跳过 YARA 告警)
  ✅ policy.py Property 类加两个 property 读取白名单
  ✅ policy.py PolicyEngine.decide() 步骤 -1: 白名单过滤 (优先级最高)
  ✅ scanner.py decide() 调用补传 md5

外部资源缺口 (3 条,README 已记录):
  - L1-009/010 需真实签名 .cvd 文件
  - L1-014 需压测脚本 stress_test.py

测试靶场:
  ✅ rules/test_rules.yar (7 条自动生成 YARA 规则)
  ✅ README 新增测试靶场待办 + 白名单机制说明 + 脚本清单
This commit is contained in:
2026-10-29 14:40:49 +08:00
parent 6e3c1a40e5
commit 57d89b3d05
7 changed files with 125 additions and 1 deletions

View File

@@ -224,4 +224,30 @@ MeshSafe 会**分析宏的实际行为**:
--- ---
## 测试靶场(待办)
CSV 用例集共 126 条,引擎能力覆盖约 97%。以下 3 条因需要外部资源暂搁置,待办环境就绪后补全:
| 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 控制并发度 |
### 白名单机制(v0.2.1 新增)
- **关键词白名单** (`policy.yaml → keyword_whitelist`):规则命中后若 detail 包含内网 IP/域名,自动放行
- **YARA 哈希白名单** (`policy.yaml → yara_hash_whitelist`):YARA 命中后 MD5 在白名单 → 跳过告警
- 生产部署时按需补充内网地址段 / 已知安全工具哈希
### 靶场样本生成脚本
| 脚本 | 用途 |
|------|------|
| `scripts/generate_csv_samples.py` | 从 CSV 批量生成 CSV 对齐样本 + index.json |
| `scripts/generate_yara_rules.py` | 自动生成 CSV L4 段对应的 YARA `.yar` 规则 |
| `rules/test_rules.yar` | 生成的规则文件(引擎按 mtime 自动热加载) |
---
*更多技术细节请参阅 [总体方案](docs/00-总体方案.md) 和 [接口文档](docs/接口文档.md)* *更多技术细节请参阅 [总体方案](docs/00-总体方案.md) 和 [接口文档](docs/接口文档.md)*

View File

@@ -27,7 +27,14 @@ from app.engines.base import Finding, ScanTarget, Severity
ARCHIVE_EXTS = {".zip", ".jar", ".war", ".apk", ".epub", ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp"} ARCHIVE_EXTS = {".zip", ".jar", ".war", ".apk", ".epub", ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp"}
TAR_EXTS = {".tar", ".gz", ".tgz", ".bz2", ".xz", ".tbz2", ".txz"} TAR_EXTS = {".tar", ".gz", ".tgz", ".bz2", ".xz", ".tbz2", ".txz"}
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._一-鿿-]+") _SAFE_NAME_RE = re.compile(
r"[^A-Za-z0-9._一-鿿" # ASCII + CJK 统一汉字
r"\U0001F300-\U0001FAFF" # Emoji 表情符号
r"\U0001F000-\U0001F2FF" # 符号和象形文字扩展 A
r"\U00002600-\U000027BF" # 杂项符号 + 装饰符号
r"\U00002B00-\U00002BFF" # 箭头等
r"-]+"
)
@dataclass @dataclass

View File

@@ -869,6 +869,7 @@ class StructureEngine(Engine):
total_uncompressed = 0 total_uncompressed = 0
executable_members: List[str] = [] executable_members: List[str] = []
traversal_members: List[str] = [] traversal_members: List[str] = []
long_filename_members: List[str] = []
member_count = 0 member_count = 0
encrypted_count = 0 encrypted_count = 0
@@ -886,6 +887,8 @@ class StructureEngine(Engine):
traversal_members.append(name) traversal_members.append(name)
if info.flag_bits & 0x1: # ZIP 加密标志(bit 0) if info.flag_bits & 0x1: # ZIP 加密标志(bit 0)
encrypted_count += 1 encrypted_count += 1
if len(os.path.basename(name)) > 120:
long_filename_members.append(name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return [ return [
Finding( Finding(
@@ -970,6 +973,18 @@ class StructureEngine(Engine):
) )
) )
if long_filename_members:
findings.append(
Finding(
engine=self.name,
layer=self.layer,
severity=Severity.LOW.value,
rule="LongFilename",
detail=f"归档内存在超长文件名(>120 字符){len(long_filename_members)} 个,可能规避扫描或探测边界",
weight=5.0,
)
)
return findings return findings
def _inspect_tar(self, target: ScanTarget, max_ratio: int, max_total: int) -> List[Finding]: def _inspect_tar(self, target: ScanTarget, max_ratio: int, max_total: int) -> List[Finding]:

View File

@@ -99,6 +99,17 @@ class Policy:
"""内网特征词表规则(L2B 引擎使用)。""" """内网特征词表规则(L2B 引擎使用)。"""
return self.raw.get("keyword_rules", []) or [] 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 @property
def yara_weights(self) -> Dict[str, float]: def yara_weights(self) -> Dict[str, float]:
"""YARA 规则权重映射:规则名 → weight(0-100)。 """YARA 规则权重映射:规则名 → weight(0-100)。
@@ -387,6 +398,7 @@ class PolicyEngine:
real_type: str = "", real_type: str = "",
engine_errors: Optional[List[str]] = None, engine_errors: Optional[List[str]] = None,
encrypted: bool = False, encrypted: bool = False,
md5: str = "",
) -> Decision: ) -> Decision:
"""综合所有信号产出最终裁决。 """综合所有信号产出最终裁决。
@@ -410,6 +422,38 @@ class PolicyEngine:
ext = (declared_ext or "").lower() 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:零容忍规则优先检查 # 步骤 0:零容忍规则优先检查
# ====================================================================== # ======================================================================

View File

@@ -453,6 +453,7 @@ class Scanner:
real_type=real_type, real_type=real_type,
engine_errors=engine_errors, engine_errors=engine_errors,
encrypted=encrypted, encrypted=encrypted,
md5=root_info.get("md5", ""),
) )
# 计算 open_risk(独立于裁决结果,供人阅读) # 计算 open_risk(独立于裁决结果,供人阅读)

View File

@@ -398,6 +398,37 @@ keyword_rules:
weight: 30 weight: 30
severity: high severity: high
# ---------- 白名单(规则命中后二次放宽) ----------
# 优先级:白名单 > 关键词规则 > YARA 规则 > 评分累加
# 命中白名单的规则会从 findings 中过滤,不再进入裁决
keyword_whitelist:
# ---- 内网合法运维地址/域名(白名单放行 curl/wget 下载)----
- rule: RemoteDownloadUnix # curl/wget 下载特征
allowed_keywords:
- "192.168."
- "10."
- "172.16."
- "172.17."
- "172.18."
- "172.19."
- "172.2"
- "172.3"
- ".senmesh.com"
- "repo.internal"
- "mirror.internal"
- rule: RemoteDownloadPowerShell # Invoke-WebRequest/BitsAdmin
allowed_keywords:
- "192.168."
- "10."
- ".senmesh.com"
yara_hash_whitelist:
# YARA 命中后,文件 MD5 在此列表 → 跳过告警
# 典型场景:已知安全的开源工具被 YARA 误匹配
- md5: "00000000000000000000000000000000"
note: "示例:占位,生产按需补充"
# ---------- 审计 ---------- # ---------- 审计 ----------
audit: audit:
# 记录扫描结果明细到审计日志 # 记录扫描结果明细到审计日志