@yottameta/yotta-verify-mcp-plugin 0.0.0 → 0.2.3
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.
- package/.agents/plugins/marketplace.json +25 -0
- package/.claude-plugin/marketplace.json +25 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/mcp.json +12 -0
- package/package.json +32 -13
- package/plugin.json +20 -0
- package/skills/yotta-verify-mcp/LICENSE +21 -0
- package/skills/yotta-verify-mcp/NOTICE +15 -0
- package/skills/yotta-verify-mcp/SKILL.md +144 -0
- package/skills/yotta-verify-mcp/assets/banner.png +0 -0
- package/skills/yotta-verify-mcp/bin/yotta-verify-mcp.js +66 -0
- package/skills/yotta-verify-mcp/references/trust-checklist.md +44 -0
- package/skills/yotta-verify-mcp/scripts/threat_engine.py +350 -0
- package/skills/yotta-verify-mcp/scripts/verify_rules.py +449 -0
- package/skills/yotta-verify-mcp/scripts/yotta_verify.py +902 -0
- package/skills/yotta-verify-mcp/scripts/yotta_verify_mcp.py +335 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""threat_engine.py — yotta-verify(元信)威胁捕获引擎(2026-08-30 增强)。
|
|
3
|
+
|
|
4
|
+
L2/L3 引擎(8 检测点威胁捕获模型 + 13 行为项口径,见 verify_rules 的
|
|
5
|
+
THREAT_TAXONOMY / DETECTOR_TO_TAXONOMY / BEHAVIORS / DETECTOR_TO_BEHAVIORS):
|
|
6
|
+
|
|
7
|
+
- L3 MCP 工具面(analyze_mcp_tool_surface):识别 MCP server 的工具集,
|
|
8
|
+
逐工具追踪「工具参数 → 危险 sink」的数据流,输出工具级 finding
|
|
9
|
+
(命令执行 / 任意文件读写;含 OWASP LLM06 过度授权视角)。
|
|
10
|
+
- L2 数据流(analyze_dataflow):识别「不可信输入源 → 危险 sink」的可达路径
|
|
11
|
+
(MCP 参数 / argv / env / 读入文件内容)。
|
|
12
|
+
|
|
13
|
+
设计原则(与元信一致):
|
|
14
|
+
- 纯 Python 3.8+ 标准库、零依赖;只读静态、不执行被测代码。
|
|
15
|
+
- 轻量近似(正则级 taint):宁可漏报也不误伤安全用法 —— 命中「防护上下文」
|
|
16
|
+
(shell:false / 路径校验 / 白名单 / 本地 CLI 参数)即判定受控,不升级判级。
|
|
17
|
+
- 判级宁严勿松:确认「MCP 参数 → 危险 sink 无防护」才给 critical/high。
|
|
18
|
+
|
|
19
|
+
本模块只返回 finding 字典列表(dict),由 yotta_verify.py 转为 Finding 对象。
|
|
20
|
+
"""
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
# ── 危险 sink(命令 / 文件写 / 文件读)──────────────────────────────────────
|
|
24
|
+
CMD_SINK_RE = re.compile(
|
|
25
|
+
r"(?i)(?:spawnSync|execSync|child_process\.spawn|child_process\.exec|"
|
|
26
|
+
r"child_process\.execFile|\bexec\b|\bexecFile\b|Popen|os\.system|"
|
|
27
|
+
r"os\.popen|subprocess\.call|subprocess\.run|subprocess\.Popen|\beval\b|"
|
|
28
|
+
r"\bFunction\b)\s*\(")
|
|
29
|
+
FILE_WRITE_SINK_RE = re.compile(
|
|
30
|
+
r"(?i)(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|"
|
|
31
|
+
r"shutil\.copy|shutil\.move|os\.rename)\s*\(")
|
|
32
|
+
FILE_READ_SINK_RE = re.compile(
|
|
33
|
+
r"(?i)(?:readFile|readFileSync|createReadStream)\s*\(")
|
|
34
|
+
|
|
35
|
+
# ── MCP server 特征(命中任一即视为 MCP 实现面)────────────────────────────
|
|
36
|
+
MCP_MARKERS = (
|
|
37
|
+
"TOOL_HANDLERS", "callTool", "tools/call", "mcpTools", "params.get",
|
|
38
|
+
"params[", "arguments[", "inputSchema", "MCP", "stdio", "tool_params",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# ── 不可信输入源(MCP 工具参数 / argv / env)────────────────────────────────
|
|
42
|
+
MCP_SOURCE_RE = re.compile(
|
|
43
|
+
r"(?i)\b(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"
|
|
44
|
+
r"(?:params\.get\(|params\[|arguments\[|req\.params|tool_params)")
|
|
45
|
+
ARGV_SOURCE_RE = re.compile(r"\b(?:process\.argv|sys\.argv)")
|
|
46
|
+
# 本地 CLI 参数对象(非 MCP 注入面 → 不判危险)
|
|
47
|
+
LOCAL_CLI_RE = re.compile(r"\b(?:opts|options|config|args|argv)\s*[.\[]")
|
|
48
|
+
|
|
49
|
+
# ── 安全防护上下文(sink 行 ± 窗口内出现即判定受控)────────────────────────
|
|
50
|
+
CMD_SAFE_MARKERS = (
|
|
51
|
+
"shell: false", "shell:false", "shell=False", "shell: False",
|
|
52
|
+
"argv[0]", "argv.slice", "allowlist", "白名单", "允许清单", "固定命令",
|
|
53
|
+
"model allowlist", "allowed", "安全", "校验", "验证",
|
|
54
|
+
)
|
|
55
|
+
FILE_SAFE_MARKERS = (
|
|
56
|
+
"resolveWithinRoot", "isWithinRoot", "within root", "记忆库目录", "memory root",
|
|
57
|
+
"path.join(root", "path.resolve(root", "path.join(memoryRoot", "allowlist",
|
|
58
|
+
"白名单", "允许清单", "固定路径", "安全", "校验", "验证",
|
|
59
|
+
)
|
|
60
|
+
SAFE_WINDOW = 3 # 命中行前后各 N 行
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _collect_mcp_sources(lines):
|
|
64
|
+
"""收集 MCP 不可信源变量:{变量名: 来源描述}。"""
|
|
65
|
+
sources = {}
|
|
66
|
+
for line in lines:
|
|
67
|
+
m = MCP_SOURCE_RE.search(line)
|
|
68
|
+
if m:
|
|
69
|
+
sources[m.group(1)] = "MCP 工具参数"
|
|
70
|
+
return sources
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _window_text(lines, idx):
|
|
74
|
+
lo = max(0, idx - SAFE_WINDOW)
|
|
75
|
+
hi = min(len(lines), idx + SAFE_WINDOW + 1)
|
|
76
|
+
return "\n".join(lines[lo:hi])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_marker(text, markers):
|
|
80
|
+
low = text.lower()
|
|
81
|
+
return any(m.lower() in low for m in markers)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_identifiers(args_text):
|
|
85
|
+
"""提取调用参数文本中的标识符(粗筛)。"""
|
|
86
|
+
return set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", args_text))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_mcp_file(text):
|
|
90
|
+
return any(m in text for m in MCP_MARKERS)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _judge_sink(lines, idx, line, sink_label, mcp_sources, kind):
|
|
94
|
+
"""对单个 sink 调用判级。返回 finding dict 或 None。
|
|
95
|
+
|
|
96
|
+
kind: "cmd" / "write" / "read"
|
|
97
|
+
"""
|
|
98
|
+
ctx = _window_text(lines, idx)
|
|
99
|
+
mcp_only = {k: v for k, v in mcp_sources.items()}
|
|
100
|
+
if not mcp_only:
|
|
101
|
+
return None
|
|
102
|
+
# 参数文本
|
|
103
|
+
args_start = line.find("(")
|
|
104
|
+
args_text = line[args_start + 1:] if args_start >= 0 else ""
|
|
105
|
+
ids = _extract_identifiers(args_text)
|
|
106
|
+
if not ids:
|
|
107
|
+
return None
|
|
108
|
+
# 是否有 MCP 源变量进入该 sink 调用
|
|
109
|
+
tainted = ids & set(mcp_only.keys())
|
|
110
|
+
if not tainted:
|
|
111
|
+
return None
|
|
112
|
+
# 本地 CLI 参数对象(非 MCP 注入)→ 不算 MCP 面
|
|
113
|
+
if _has_marker(line, ("opts.", "options.", "config.")):
|
|
114
|
+
return None
|
|
115
|
+
if kind == "cmd":
|
|
116
|
+
if _has_marker(ctx, CMD_SAFE_MARKERS):
|
|
117
|
+
return None # 受控(shell:false / 白名单 / argv 数组)
|
|
118
|
+
sev, conf, desc = "critical", 93, "MCP 工具参数流入子进程执行(%s),无 shell/白名单防护" % sink_label
|
|
119
|
+
tax = "command_execution"
|
|
120
|
+
elif kind == "write":
|
|
121
|
+
if _has_marker(ctx, FILE_SAFE_MARKERS):
|
|
122
|
+
return None # 路径经 root 校验/白名单
|
|
123
|
+
sev, conf, desc = "high", 90, "MCP 工具参数流入文件写操作(%s),任意路径写入风险" % sink_label
|
|
124
|
+
tax = "file_access"
|
|
125
|
+
else:
|
|
126
|
+
if _has_marker(ctx, FILE_SAFE_MARKERS):
|
|
127
|
+
return None
|
|
128
|
+
sev, conf, desc = "high", 88, "MCP 工具参数流入文件读操作(%s),任意路径读取风险" % sink_label
|
|
129
|
+
tax = "file_access"
|
|
130
|
+
behaviors = ()
|
|
131
|
+
if kind == "write":
|
|
132
|
+
behaviors = ("写入文件",)
|
|
133
|
+
elif kind == "read":
|
|
134
|
+
behaviors = ("读取文件",)
|
|
135
|
+
return {
|
|
136
|
+
"detector": "MCPToolSurface",
|
|
137
|
+
"severity": sev,
|
|
138
|
+
"category": tax,
|
|
139
|
+
"rule_id": "L3-" + sink_label.split("(")[0].upper()[:12],
|
|
140
|
+
"description": desc + "(源变量: %s)" % ", ".join(sorted(tainted)),
|
|
141
|
+
"confidence": conf,
|
|
142
|
+
"behaviors": behaviors,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def analyze_mcp_tool_surface(files, read_lines):
|
|
147
|
+
"""L3:MCP 工具面 → 危险 sink 数据流,返回 finding dict 列表。"""
|
|
148
|
+
findings = []
|
|
149
|
+
for path, rel in files:
|
|
150
|
+
lines = read_lines(path)
|
|
151
|
+
text = "\n".join(lines)
|
|
152
|
+
if not _is_mcp_file(text):
|
|
153
|
+
continue
|
|
154
|
+
mcp_sources = _collect_mcp_sources(lines)
|
|
155
|
+
if not mcp_sources:
|
|
156
|
+
continue
|
|
157
|
+
for idx, line in enumerate(lines):
|
|
158
|
+
for kind, rx, label in (
|
|
159
|
+
("cmd", CMD_SINK_RE, "命令"),
|
|
160
|
+
("write", FILE_WRITE_SINK_RE, "文件写"),
|
|
161
|
+
("read", FILE_READ_SINK_RE, "文件读")):
|
|
162
|
+
m = rx.search(line)
|
|
163
|
+
if m:
|
|
164
|
+
f = _judge_sink(lines, idx, line, label, mcp_sources, kind)
|
|
165
|
+
if f:
|
|
166
|
+
f["file"] = rel
|
|
167
|
+
f["line"] = idx + 1
|
|
168
|
+
findings.append(f)
|
|
169
|
+
return findings
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def analyze_dataflow(files, read_lines):
|
|
173
|
+
"""L2:不可信输入源(argv/env/读入文件)→ 危险 sink 的可达路径(轻量近似)。
|
|
174
|
+
|
|
175
|
+
与 L3 的区别:L3 只针对 MCP 工具面;L2 覆盖 CLI/env 输入源。
|
|
176
|
+
返回 finding dict 列表(高置信才判级,否则不报,避免误伤)。
|
|
177
|
+
"""
|
|
178
|
+
findings = []
|
|
179
|
+
for path, rel in files:
|
|
180
|
+
lines = read_lines(path)
|
|
181
|
+
argv_vars = {}
|
|
182
|
+
for _ln, _line in enumerate(lines):
|
|
183
|
+
m = re.search(
|
|
184
|
+
r"(?i)\b(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*process\.argv",
|
|
185
|
+
_line)
|
|
186
|
+
if m:
|
|
187
|
+
argv_vars[m.group(1)] = _ln
|
|
188
|
+
if not argv_vars and not any(ARGV_SOURCE_RE.search(l) for l in lines):
|
|
189
|
+
continue
|
|
190
|
+
for idx, line in enumerate(lines):
|
|
191
|
+
m = CMD_SINK_RE.search(line)
|
|
192
|
+
if not m:
|
|
193
|
+
continue
|
|
194
|
+
ctx = _window_text(lines, idx)
|
|
195
|
+
if _has_marker(ctx, CMD_SAFE_MARKERS):
|
|
196
|
+
continue
|
|
197
|
+
args_start = line.find("(")
|
|
198
|
+
args_text = line[args_start + 1:] if args_start >= 0 else ""
|
|
199
|
+
ids = _extract_identifiers(args_text)
|
|
200
|
+
direct = bool(ARGV_SOURCE_RE.search(line))
|
|
201
|
+
# argv 变量须在 sink 之前且相距 ≤ 50 行(避免跨函数同名误报)
|
|
202
|
+
tainted = set(v for v in (ids & set(argv_vars.keys()))
|
|
203
|
+
if 0 <= (idx - argv_vars[v]) <= 50)
|
|
204
|
+
if not direct and not tainted:
|
|
205
|
+
continue
|
|
206
|
+
findings.append({
|
|
207
|
+
"detector": "Dataflow",
|
|
208
|
+
"severity": "medium",
|
|
209
|
+
"category": "command_execution",
|
|
210
|
+
"rule_id": "L2-CMD",
|
|
211
|
+
"description": "命令行参数(argv)流入子进程执行且无防护,建议人工复核"
|
|
212
|
+
"(源变量: %s)" % (", ".join(sorted(tainted)) or "argv 直接"),
|
|
213
|
+
"confidence": 70,
|
|
214
|
+
"file": rel,
|
|
215
|
+
"line": idx + 1,
|
|
216
|
+
})
|
|
217
|
+
return findings
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
222
|
+
# 综合报告视图(2026-08-30 增强:双视角报告 + 评分 + 逐文件)
|
|
223
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
224
|
+
|
|
225
|
+
SEVERITY_DEDUCT = {"critical": 45, "high": 25, "medium": 10, "low": 3, "info": 0}
|
|
226
|
+
# 评分扣分权重 + 封顶(低危密集不扣光;中高危为主要扣分)
|
|
227
|
+
SCORE_WEIGHTS = {"critical": 40, "high": 20, "medium": 8, "low": 1, "info": 0}
|
|
228
|
+
SCORE_CAPS = {"critical": 2, "high": 4, "medium": 6, "low": 10, "info": 0}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def health_score(findings):
|
|
232
|
+
"""安全健康度评分 0-100(100 起扣 + 封顶;取整下限 0)。"""
|
|
233
|
+
counts = {}
|
|
234
|
+
for f in findings:
|
|
235
|
+
sev = f.get("severity", "info")
|
|
236
|
+
counts[sev] = counts.get(sev, 0) + 1
|
|
237
|
+
score = 100
|
|
238
|
+
for sev, w in SCORE_WEIGHTS.items():
|
|
239
|
+
score -= w * min(counts.get(sev, 0), SCORE_CAPS[sev])
|
|
240
|
+
return max(0, int(round(score)))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def taxonomy_view(findings, taxonomy, order, det_to_tax):
|
|
244
|
+
"""8 类威胁图谱:每类 verdict(danger/suspicious/safe/n/a)。"""
|
|
245
|
+
hits = {}
|
|
246
|
+
for f in findings:
|
|
247
|
+
det = f.get("detector", "")
|
|
248
|
+
key = det_to_tax.get(det, "other")
|
|
249
|
+
hits.setdefault(key, []).append(f)
|
|
250
|
+
out = {}
|
|
251
|
+
for key in order:
|
|
252
|
+
items = hits.get(key, [])
|
|
253
|
+
name = taxonomy.get(key, key)
|
|
254
|
+
sev = "info"
|
|
255
|
+
for f in items:
|
|
256
|
+
s = f.get("severity", "info")
|
|
257
|
+
if SEVERITY_DEDUCT.get(s, 0) > SEVERITY_DEDUCT.get(sev, 0):
|
|
258
|
+
sev = s
|
|
259
|
+
if not items:
|
|
260
|
+
verdict = "n/a"
|
|
261
|
+
elif sev in ("critical", "high"):
|
|
262
|
+
verdict = "danger"
|
|
263
|
+
elif sev == "medium":
|
|
264
|
+
verdict = "suspicious"
|
|
265
|
+
else:
|
|
266
|
+
verdict = "safe"
|
|
267
|
+
out[key] = {
|
|
268
|
+
"name": name, "verdict": verdict, "count": len(items),
|
|
269
|
+
"severity": sev, "findings": [f.get("rule_id", "") for f in items[:5]],
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def behavior_view(findings, behaviors, det_to_behaviors):
|
|
275
|
+
"""13 行为项:observed(观察到)/ none。"""
|
|
276
|
+
observed = {}
|
|
277
|
+
for f in findings:
|
|
278
|
+
bs = f.get("behaviors")
|
|
279
|
+
if bs is None:
|
|
280
|
+
bs = det_to_behaviors.get(f.get("detector", ""), ())
|
|
281
|
+
for b in bs:
|
|
282
|
+
observed.setdefault(b, 0)
|
|
283
|
+
observed[b] += 1
|
|
284
|
+
out = []
|
|
285
|
+
for b in behaviors:
|
|
286
|
+
out.append({"behavior": b, "observed": observed.get(b, 0)})
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def file_view(findings):
|
|
291
|
+
"""逐文件 verdict:每文件最高严重级。"""
|
|
292
|
+
per_file = {}
|
|
293
|
+
for f in findings:
|
|
294
|
+
fp = f.get("file", "?")
|
|
295
|
+
sev = f.get("severity", "info")
|
|
296
|
+
if SEVERITY_DEDUCT.get(sev, 0) > SEVERITY_DEDUCT.get(per_file.get(fp, "info"), 0):
|
|
297
|
+
per_file[fp] = sev
|
|
298
|
+
return [{"file": k, "verdict": v} for k, v in sorted(per_file.items())]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_content_hash(files, read_lines):
|
|
302
|
+
"""内容 hash:全部扫描文件内容的 SHA256(确定性汇总)。"""
|
|
303
|
+
import hashlib
|
|
304
|
+
h = hashlib.sha256()
|
|
305
|
+
for path, rel in files:
|
|
306
|
+
try:
|
|
307
|
+
raw = path.read_bytes()
|
|
308
|
+
except OSError:
|
|
309
|
+
continue
|
|
310
|
+
h.update(rel.encode("utf-8", errors="replace"))
|
|
311
|
+
h.update(b"\x00")
|
|
312
|
+
h.update(raw)
|
|
313
|
+
return h.hexdigest()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def repair_guide(findings):
|
|
317
|
+
"""修复建议指南:按 taxonomy 分组给可执行建议。"""
|
|
318
|
+
guide = []
|
|
319
|
+
seen = set()
|
|
320
|
+
for f in findings:
|
|
321
|
+
if f.get("severity") not in ("critical", "high", "medium"):
|
|
322
|
+
continue
|
|
323
|
+
det = f.get("detector", "")
|
|
324
|
+
key = det
|
|
325
|
+
if key in seen:
|
|
326
|
+
continue
|
|
327
|
+
seen.add(key)
|
|
328
|
+
rule_id = f.get("rule_id", "")
|
|
329
|
+
if rule_id in ("L3-命令", "L3-文件写", "L3-文件读", "MCE-001", "MCE-002", "MCE-003"):
|
|
330
|
+
guide.append(
|
|
331
|
+
"命令执行面:对 MCP 工具/CLI 参数建立固定命令白名单,子进程禁用 shell "
|
|
332
|
+
"(shell:false),参数拆为 argv 数组;确认远端调用者无命令注入能力。")
|
|
333
|
+
elif det == "MCPFileAccess" or rule_id.startswith("MFA") or "文件" in (f.get("category") or ""):
|
|
334
|
+
guide.append(
|
|
335
|
+
"文件操作面:将 MCP 工具的读写限制在声明目录内(路径归一化 + root 校验),"
|
|
336
|
+
"禁止任意路径;对敏感文件(密钥/凭据)读取需人工确认。")
|
|
337
|
+
elif rule_id.startswith("PTV"):
|
|
338
|
+
guide.append(
|
|
339
|
+
"路径穿越:路径拼接前做归一化与根目录校验,拒绝父目录逃逸与绝对路径越界。")
|
|
340
|
+
elif det == "PromptInjection" or rule_id.startswith("PIJ"):
|
|
341
|
+
guide.append(
|
|
342
|
+
"提示注入:技能/工具描述视为不可信数据,去除非必要指令性话术;敏感操作先问用户。")
|
|
343
|
+
elif rule_id.startswith("DEX") or rule_id.startswith("NET-0"):
|
|
344
|
+
guide.append(
|
|
345
|
+
"远程下载/网络面:移除『下载后立即执行』的链路,外发数据需用户确认与白名单。")
|
|
346
|
+
else:
|
|
347
|
+
guide.append(
|
|
348
|
+
"%s:按报告逐条复核并修复,涉及系统/凭据/持久化面需最小权限化。"
|
|
349
|
+
% (f.get("category") or det))
|
|
350
|
+
return guide
|