@yottameta/yotta-verify-mcp-plugin 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Security",
15
- "version": "0.4.2",
15
+ "version": "0.4.4",
16
16
  "description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
17
17
  "interface": {
18
18
  "displayName": "元信MCP yotta-verify-mcp",
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Security",
15
- "version": "0.4.2",
15
+ "version": "0.4.4",
16
16
  "description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
17
17
  "interface": {
18
18
  "displayName": "元信MCP yotta-verify-mcp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-verify-mcp-plugin",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "YuanXinMCP (元信MCP) — pre-install security scanning for Agent skills, packaged as a YottaMeta Agent Plugin.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "yotta-verify-mcp",
4
- "version": "0.4.2",
4
+ "version": "0.4.4",
5
5
  "description": "YuanXinMCP (元信MCP) - the pre-install security scanner for Agent skills, exposed as a stdio MCP server: scan_skill (dir/package -> verdict + findings), generate_badge (audited badge), gate_check (CI gate), get_report (JSON/Markdown). Local offline static scan; no upload of scanned content, no execution of scanned code; human confirmation required.",
6
6
  "author": {
7
7
  "name": "YottaMeta",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: yotta-verify-mcp
3
3
  description: 元信MCP(yotta-verify-mcp)—— 把元信(yotta-verify)装前安全扫描暴露为 stdio MCP server,提供 scan_skill(目录/包 → verdict+发现)、generate_badge(audited 徽章)、gate_check(CI 闸门)、get_report(JSON/Markdown 双视角综合报告)四个 MCP 工具。触发:给 MCP 客户端配置元信MCP、把装前信任扫描接入智能体/工作流、调用上列 MCP 工具做扫描;或用户说 MCP元信/元信MCP/trust-mcp/scan-mcp 等。边界:本地离线静态扫描,不上传被测内容、不执行被测代码、不联网;结论需人工确认。MCP 配置与全局/永久记忆写入均为可选,必须先获得用户明确同意;未获同意前不写任何文件,可直接降级 CLI。
4
- version: 0.4.2
4
+ version: 0.4.4
5
5
  license: MIT
6
6
  ---
7
7
 
@@ -30,6 +30,7 @@ exit code 语义(与元安/元审一致):
30
30
  """
31
31
  import argparse
32
32
  import base64
33
+ import hashlib
33
34
  import json
34
35
  import re
35
36
  import sys
@@ -52,7 +53,7 @@ sys.path.insert(0, str(_HERE))
52
53
  import verify_rules # noqa: E402
53
54
  import threat_engine # noqa: E402
54
55
 
55
- VERSION = "0.4.2"
56
+ VERSION = "0.4.4"
56
57
  TOOL_NAME = "yotta-verify"
57
58
  CN_NAME = "元信"
58
59
 
@@ -74,8 +75,63 @@ DOTFILE_NAMES = {
74
75
  MAX_FILE_SIZE = 1_000_000
75
76
  MAX_LINE_LEN = verify_rules.MAX_LINE_LEN
76
77
  MAX_FILES = 2000
77
- # 签名数据文件:规则表是扫描器自身的签名数据库,不是被测技能行为,扫描时跳过
78
+ # v0.4.4:不可静态分析的可执行 / 二进制载荷不再静默跳过(fail-closed)。
79
+ # 旧行为只对 TEXT_EXTENSIONS 做内容分析,`.exe` / `.dll` / `.jar` / 无扩展名二进制
80
+ # 会被直接忽略——攻击方可以把载荷放进这些文件逃避扫描。现在按扩展名 + 魔数识别,
81
+ # 命中即出 STR-009(medium),扫描结论至少为「需复核」。
82
+ OPAQUE_EXTENSIONS = {
83
+ ".exe", ".dll", ".sys", ".com", ".msi", ".scr", ".cab", ".cpl",
84
+ ".so", ".dylib", ".bin", ".o", ".a",
85
+ ".jar", ".class", ".apk", ".dex", ".wasm", ".node",
86
+ }
87
+ OPAQUE_MAGIC = (b"\x7fELF", b"MZ", b"\xca\xfe\xba\xbe", b"\xcf\xfa\xed\xfe")
88
+ # tarball 解包上限(防资源耗尽)
89
+ MAX_TARBALL_MEMBERS = 2000
90
+ MAX_TARBALL_MEMBER_BYTES = 50 * 1024 * 1024
91
+ MAX_TARBALL_TOTAL_BYTES = 200 * 1024 * 1024
92
+ # 签名数据文件:规则表是扫描器自身与家族检测技能的签名数据库,不是被测技能行为。
93
+ # v0.3.2 收紧(旧行为 = 按文件名全局豁免,`scripts/<同名>.py` 可被随意伪造以逃避扫描):
94
+ # ① 相对路径必须恰为 scripts/<规则表名>.py;
95
+ # ② 文件 SHA-256(CRLF 归一化)必须命中「已发布规则表摘要表」。
96
+ # 任一条不满足 → 正常扫描(fail-closed),杜绝「改名即豁免」与「放标记文件即降级」。
78
97
  SIGNATURE_DATA_FILES = {"verify_rules.py", "audit_rules.py", "vetter_rules.py", "hardening_rules.py"}
98
+ SIGNATURE_DATA_DIGESTS = {
99
+ "verify_rules.py": {"8d8c128911b05b24413cc3aec8a2d1c36c1ccf66f656330f723cd68718bda8bd"},
100
+ "vetter_rules.py": {"fc5bad6a7705f9fde60f4fdaf540a3aaa6490448d1fe22ed264e3f812c8ba092"},
101
+ "audit_rules.py": {"475ba1daee436589997260291917126218ae7cae572d16eb59459ca5a2192d23"},
102
+ "hardening_rules.py": {"6b9cbdaa106ae7f5827e60f83c016c237d4cd28785426930abc24cde00bcf5c7"},
103
+ "guardian_rules.py": {"a9fc0da3e0344d485b20c55378b58bc81d78ecbb28a7f2b3bd44bcbed06aebe3"},
104
+ }
105
+
106
+
107
+ def _sha256_normalized(path):
108
+ """CRLF 归一化后的 SHA-256(跨 Windows / Linux 检出结果一致)。"""
109
+ try:
110
+ data = path.read_bytes().replace(b"\r\n", b"\n")
111
+ except OSError:
112
+ return None
113
+ return hashlib.sha256(data).hexdigest()
114
+
115
+
116
+ def is_signature_data(rel, path):
117
+ """是否为「已发布」的家族规则表:路径 + 内容摘要双绑定。"""
118
+ known = SIGNATURE_DATA_DIGESTS.get(path.name)
119
+ if not known:
120
+ return False
121
+ parts = str(rel).replace("\\", "/").split("/")
122
+ if len(parts) != 2 or parts[0] != "scripts":
123
+ return False
124
+ digest = _sha256_normalized(path)
125
+ return bool(digest) and digest in known
126
+
127
+
128
+ def has_published_signature_data(root):
129
+ """目标包是否持有已发布签名数据(用于测试夹具跳过与文档降级判定)。"""
130
+ for name in SIGNATURE_DATA_DIGESTS:
131
+ p = Path(root) / "scripts" / name
132
+ if p.is_file() and is_signature_data("scripts/" + name, p):
133
+ return True
134
+ return False
79
135
 
80
136
  # ── 严重级 / verdict ───────────────────────────────────────────────────────
81
137
  _SEVERITY_VALUE = verify_rules.SEVERITY_VALUE
@@ -143,19 +199,26 @@ def is_text_file(name):
143
199
  return Path(p).suffix in TEXT_EXTENSIONS
144
200
 
145
201
 
146
- def walk_files(root, base=""):
147
- """递归收集可扫描文本文件(跳过 SKIP_DIRS / 签名数据 / 超限)。"""
202
+ def walk_files(root, base="", trusted_tests=None, scan_root=None):
203
+ """递归收集可扫描文本文件(跳过 SKIP_DIRS / 已发布签名数据 / 超限)。
204
+
205
+ trusted_tests 只在「目标包持有已发布签名数据」时为真——此时 `test_*.py`
206
+ 视为自家测试夹具跳过;第三方包的同名文件照常扫描(v0.3.2 起)。
207
+ """
148
208
  out = []
209
+ if scan_root is None:
210
+ scan_root = root
211
+ trusted_tests = has_published_signature_data(scan_root)
149
212
  try:
150
213
  entries = sorted(root.iterdir())
151
214
  except OSError:
152
215
  return out
153
216
  for entry in entries:
154
- if entry.name in SKIP_DIRS or entry.name in SIGNATURE_DATA_FILES:
217
+ if entry.name in SKIP_DIRS:
155
218
  continue
156
219
  rel = entry.name if not base else base + "/" + entry.name
157
220
  if entry.is_dir():
158
- out.extend(walk_files(entry, rel))
221
+ out.extend(walk_files(entry, rel, trusted_tests, scan_root))
159
222
  elif entry.is_file():
160
223
  try:
161
224
  size = entry.stat().st_size
@@ -163,8 +226,11 @@ def walk_files(root, base=""):
163
226
  continue
164
227
  if size > MAX_FILE_SIZE:
165
228
  continue
166
- # 测试文件为签名/测试数据(含构造的恶意样例),非被测技能行为,跳过
167
- if entry.name.startswith("test_") and entry.name.endswith(".py"):
229
+ # 已发布规则表(路径 + 摘要绑定)不是被测技能行为,跳过
230
+ if is_signature_data(rel, entry):
231
+ continue
232
+ # 自家包的测试夹具(含构造样例)跳过;第三方包不豁免
233
+ if trusted_tests and entry.name.startswith("test_") and entry.name.endswith(".py"):
168
234
  continue
169
235
  if is_text_file(entry.name):
170
236
  out.append((entry, rel))
@@ -173,6 +239,57 @@ def walk_files(root, base=""):
173
239
  return out
174
240
 
175
241
 
242
+ def _read_magic(path, size=4):
243
+ try:
244
+ with open(str(path), "rb") as fh:
245
+ return fh.read(size)
246
+ except OSError:
247
+ return b""
248
+
249
+
250
+ def find_opaque_files(root):
251
+ """列出无法静态分析、但带可执行 / 二进制特征的文件(v0.4.4,fail-closed)。"""
252
+ out = []
253
+ if root.is_file():
254
+ candidates = [(root, root.name)]
255
+ else:
256
+ candidates = []
257
+ stack = [root]
258
+ while stack:
259
+ cur = stack.pop()
260
+ try:
261
+ entries = sorted(cur.iterdir())
262
+ except OSError:
263
+ continue
264
+ for entry in entries:
265
+ if entry.name in SKIP_DIRS:
266
+ continue
267
+ if entry.is_dir():
268
+ stack.append(entry)
269
+ elif entry.is_file():
270
+ rel = str(entry.relative_to(root)).replace("\\", "/")
271
+ candidates.append((entry, rel))
272
+ for path, rel in candidates:
273
+ name = path.name.lower()
274
+ if name in DOTFILE_NAMES or Path(name).suffix in TEXT_EXTENSIONS:
275
+ continue
276
+ suffix = Path(name).suffix
277
+ opaque = suffix in OPAQUE_EXTENSIONS
278
+ if not opaque:
279
+ magic = _read_magic(path)
280
+ opaque = any(magic.startswith(sig) for sig in OPAQUE_MAGIC)
281
+ if not opaque:
282
+ continue
283
+ try:
284
+ size = path.stat().st_size
285
+ except OSError:
286
+ size = 0
287
+ out.append((rel, size))
288
+ if len(out) >= 50:
289
+ break
290
+ return out
291
+
292
+
176
293
  def read_lines(path):
177
294
  """读取文本文件行列表(容错编码;超长行截断)。"""
178
295
  try:
@@ -420,11 +537,12 @@ _DOC_EXT = {".md", ".txt", ".markdown", ".rst"}
420
537
 
421
538
 
422
539
  def is_detector_skill(root):
423
- """目标目录是否含检测器签名文件(audit_rules/verify_rules/vetter_rules/hardening_rules)。"""
424
- for name in _DETECTOR_SIG_FILES:
425
- if (root / name).is_file() or (root / "scripts" / name).is_file():
426
- return True
427
- return False
540
+ """目标是否为家族检测技能(v0.3.2:须持「已发布」规则表,路径 + 摘要绑定)。
541
+
542
+ 旧行为 = 只看目录里有没有同名文件(放一个标记文件即可把整包文档命中降级为
543
+ info)。现改为内容摘要绑定:未验证的同名文件不构成检测器签名。
544
+ """
545
+ return has_published_signature_data(root)
428
546
 
429
547
 
430
548
  def downgrade_detector_docs(findings, root):
@@ -494,10 +612,15 @@ def exit_code_of(verdict):
494
612
  # ── 扫描主流程 ─────────────────────────────────────────────────────────────
495
613
 
496
614
  def _safe_extract(tf, dest):
497
- """提取 tarball(Python 3.8 兼容;拒绝路径穿越、链接与特殊文件)。"""
615
+ """提取 tarball(Python 3.8 兼容;拒绝路径穿越、链接与特殊文件;v0.4.4 加解包上限)。"""
498
616
  safe_members = []
499
617
  root = Path(dest).resolve()
500
- for member in tf.getmembers():
618
+ members = tf.getmembers()
619
+ if len(members) > MAX_TARBALL_MEMBERS:
620
+ raise ValueError("tarball 成员数超过上限: %d > %d"
621
+ % (len(members), MAX_TARBALL_MEMBERS))
622
+ total_bytes = 0
623
+ for member in members:
501
624
  name = member.name.replace("\\", "/")
502
625
  drive_path = len(name) >= 2 and name[1] == ":" and name[0].isalpha()
503
626
  if (not name or name.startswith("/") or drive_path
@@ -505,6 +628,12 @@ def _safe_extract(tf, dest):
505
628
  raise ValueError("tarball 含危险路径: %s" % name)
506
629
  if member.issym() or member.islnk() or member.isdev() or member.isfifo():
507
630
  raise ValueError("tarball 含链接或特殊文件成员: %s" % member.name)
631
+ size = int(member.size or 0)
632
+ if size > MAX_TARBALL_MEMBER_BYTES:
633
+ raise ValueError("tarball 单成员过大: %s(%d 字节)" % (member.name, size))
634
+ total_bytes += size
635
+ if total_bytes > MAX_TARBALL_TOTAL_BYTES:
636
+ raise ValueError("tarball 解包总量超过上限: %d 字节" % total_bytes)
508
637
  parts = [part for part in name.split("/") if part not in ("", ".")]
509
638
  target = root.joinpath(*parts).resolve()
510
639
  try:
@@ -572,6 +701,13 @@ def scan_core(target, name_hint=None, auto_name_hint=False):
572
701
  name_hint = resolve_name_hint(target, root, from_archive=from_archive)
573
702
  files = walk_files(root)
574
703
  findings = scan_patterns(files)
704
+ # v0.4.4:不可静态分析的可执行 / 二进制载荷按 fail-closed 处理(不再静默跳过)
705
+ for rel, size in find_opaque_files(root):
706
+ findings.append(Finding(
707
+ "Structure", "medium", "不可扫描内容",
708
+ rel, 0,
709
+ "包内含无法静态分析的可执行 / 二进制文件(%d 字节),需人工确认来源与用途"
710
+ % size, 70, "STR-009"))
575
711
  check_skill_integrity(root, findings, name_hint)
576
712
  permission_summary(files, findings)
577
713
  # 检测技能文档降级(2026-08-30):目标含检测器签名文件(audit_rules 等)→
@@ -27,7 +27,7 @@ sys.path.insert(0, str(_HERE))
27
27
 
28
28
  import yotta_verify as yv # noqa: E402
29
29
 
30
- VERSION = "0.4.2"
30
+ VERSION = "0.4.4"
31
31
  TOOL_NAME = "yotta-verify-mcp"
32
32
  CN_NAME = "元信"
33
33
  MCP_PROTOCOL_MODERN = "2026-07-28"