@yottameta/yotta-verify-mcp-plugin 0.0.0 → 0.3.0

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.
@@ -0,0 +1,902 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """yotta_verify.py — YottaMeta 元信(yotta-verify)装前安全扫描器。
4
+
5
+ 对要安装的 Agent 技能 / npm 包做确定性装前安全校验:
6
+ scan 装前安全扫描(prompt injection + 危险模式 + SKILL.md 完整性 + 权限需求)
7
+ badge audited 徽章生成(本地 SVG + shields.io URL)
8
+ report 验证报告(Markdown / JSON / text)
9
+ gate CI 闸门(--max-severity,超出即失败)
10
+
11
+ 设计原则:
12
+ - 纯 Python 3.8+ 标准库,零依赖;Windows/Linux/macOS 通用。
13
+ - 只读静态检测:绝不执行被测代码、不联网、不装包、不修复。
14
+ - 规则复用:危险模式 = 元安 audit_rules 同步副本(verify_rules.AUDIT_PATTERN_RULES);
15
+ prompt injection = 元信独有规则(verify_rules.PIJ_PATTERN_RULES)。
16
+ - 检测器可自扫(dogfooding):规则表自身为签名数据自动跳过。
17
+
18
+ exit code 语义(与元安/元审一致):
19
+ 0 = SAFE TO INSTALL(干净 / 仅有 low/info)
20
+ 1 = REVIEW REQUIRED(存在 medium)
21
+ 2 = INSTALL WITH CAUTION(存在 high)
22
+ 3 = DO NOT INSTALL(存在 critical)
23
+ 4 = 用法错误 / 致命异常
24
+
25
+ 用法示例:
26
+ python3 yotta_verify.py scan ./some-skill
27
+ python3 yotta_verify.py scan ./some-skill --json --report report.md --badge
28
+ python3 yotta_verify.py badge ./some-skill --tests 49 --version 0.1.1
29
+ python3 yotta_verify.py gate ./some-skill --max-severity medium
30
+ """
31
+ import argparse
32
+ import base64
33
+ import json
34
+ import re
35
+ import sys
36
+ import tempfile
37
+ import tarfile
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+
41
+ try:
42
+ sys.stdout.reconfigure(encoding="utf-8")
43
+ except Exception:
44
+ pass
45
+ try:
46
+ sys.stderr.reconfigure(encoding="utf-8")
47
+ except Exception:
48
+ pass
49
+
50
+ _HERE = Path(__file__).resolve().parent
51
+ sys.path.insert(0, str(_HERE))
52
+ import verify_rules # noqa: E402
53
+ import threat_engine # noqa: E402
54
+
55
+ VERSION = "0.3.0"
56
+ TOOL_NAME = "yotta-verify"
57
+ CN_NAME = "元信"
58
+
59
+ SKIP_DIRS = {
60
+ "venv", "node_modules", ".git", "__pycache__", ".mypy_cache", ".tox",
61
+ "dist", "build", ".egg-info", ".venv", ".idea", ".vscode", ".tmp",
62
+ }
63
+ TEXT_EXTENSIONS = {
64
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".sh", ".bash", ".zsh",
65
+ ".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg",
66
+ ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
67
+ ".html", ".css", ".xml", ".svg", ".plist", ".ps1", ".bat", ".cmd",
68
+ ".env", ".conf", ".properties", ".gradle",
69
+ }
70
+ DOTFILE_NAMES = {
71
+ ".env", ".env.example", ".netrc", ".pgpass", ".bashrc", ".zshrc",
72
+ ".profile", ".bash_profile", ".npmrc", ".gitconfig",
73
+ }
74
+ MAX_FILE_SIZE = 1_000_000
75
+ MAX_LINE_LEN = verify_rules.MAX_LINE_LEN
76
+ MAX_FILES = 2000
77
+ # 签名数据文件:规则表是扫描器自身的签名数据库,不是被测技能行为,扫描时跳过
78
+ SIGNATURE_DATA_FILES = {"verify_rules.py", "audit_rules.py", "vetter_rules.py", "hardening_rules.py"}
79
+
80
+ # ── 严重级 / verdict ───────────────────────────────────────────────────────
81
+ _SEVERITY_VALUE = verify_rules.SEVERITY_VALUE
82
+ _SEVERITY_ORDER = verify_rules.SEVERITY_ORDER
83
+ VERDICT_SAFE = "SAFE TO INSTALL"
84
+ VERDICT_CAUTION = "INSTALL WITH CAUTION"
85
+ VERDICT_REVIEW = "REVIEW REQUIRED"
86
+ VERDICT_BLOCK = "DO NOT INSTALL"
87
+ VERDICT_BY_SEVERITY = {
88
+ "critical": VERDICT_BLOCK,
89
+ "high": VERDICT_CAUTION,
90
+ "medium": VERDICT_REVIEW,
91
+ "low": VERDICT_SAFE,
92
+ "info": VERDICT_SAFE,
93
+ }
94
+ VERDICT_EXIT = {
95
+ VERDICT_SAFE: 0,
96
+ VERDICT_REVIEW: 1,
97
+ VERDICT_CAUTION: 2,
98
+ VERDICT_BLOCK: 3,
99
+ }
100
+ BADGE_COLORS = {
101
+ VERDICT_SAFE: "4c1",
102
+ VERDICT_REVIEW: "fe7d37",
103
+ VERDICT_CAUTION: "dfb317",
104
+ VERDICT_BLOCK: "e05d44",
105
+ }
106
+
107
+ # ── Finding ─────────────────────────────────────────────────────────────────
108
+
109
+ class Finding:
110
+ __slots__ = ("detector", "severity", "category", "file_path", "line",
111
+ "description", "confidence", "rule_id")
112
+
113
+ def __init__(self, detector, severity, category, file_path, line=0,
114
+ description="", confidence=50, rule_id=""):
115
+ self.detector = detector
116
+ self.severity = severity
117
+ self.category = category
118
+ self.file_path = file_path
119
+ self.line = line
120
+ self.description = description
121
+ self.confidence = confidence
122
+ self.rule_id = rule_id
123
+
124
+ def to_dict(self):
125
+ return {
126
+ "detector": self.detector,
127
+ "severity": self.severity,
128
+ "category": self.category,
129
+ "file": self.file_path,
130
+ "line": self.line,
131
+ "description": self.description,
132
+ "confidence": self.confidence,
133
+ "rule_id": self.rule_id,
134
+ }
135
+
136
+
137
+ # ── 文件收集 ───────────────────────────────────────────────────────────────
138
+
139
+ def is_text_file(name):
140
+ p = name.lower()
141
+ if p in DOTFILE_NAMES:
142
+ return True
143
+ return Path(p).suffix in TEXT_EXTENSIONS
144
+
145
+
146
+ def walk_files(root, base=""):
147
+ """递归收集可扫描文本文件(跳过 SKIP_DIRS / 签名数据 / 超限)。"""
148
+ out = []
149
+ try:
150
+ entries = sorted(root.iterdir())
151
+ except OSError:
152
+ return out
153
+ for entry in entries:
154
+ if entry.name in SKIP_DIRS or entry.name in SIGNATURE_DATA_FILES:
155
+ continue
156
+ rel = entry.name if not base else base + "/" + entry.name
157
+ if entry.is_dir():
158
+ out.extend(walk_files(entry, rel))
159
+ elif entry.is_file():
160
+ try:
161
+ size = entry.stat().st_size
162
+ except OSError:
163
+ continue
164
+ if size > MAX_FILE_SIZE:
165
+ continue
166
+ # 测试文件为签名/测试数据(含构造的恶意样例),非被测技能行为,跳过
167
+ if entry.name.startswith("test_") and entry.name.endswith(".py"):
168
+ continue
169
+ if is_text_file(entry.name):
170
+ out.append((entry, rel))
171
+ if len(out) >= MAX_FILES:
172
+ break
173
+ return out
174
+
175
+
176
+ def read_lines(path):
177
+ """读取文本文件行列表(容错编码;超长行截断)。"""
178
+ try:
179
+ raw = path.read_bytes()
180
+ except OSError:
181
+ return []
182
+ for enc in ("utf-8", "utf-8-sig", "gb18030", "latin-1"):
183
+ try:
184
+ text = raw.decode(enc)
185
+ break
186
+ except (UnicodeDecodeError, ValueError):
187
+ continue
188
+ else:
189
+ text = raw.decode("utf-8", errors="replace")
190
+ lines = text.split("\n")
191
+ out = []
192
+ for line in lines:
193
+ if len(line) > MAX_LINE_LEN:
194
+ out.append(line[:MAX_LINE_LEN])
195
+ else:
196
+ out.append(line)
197
+ return out
198
+
199
+
200
+ def find_skill_md(root):
201
+ """在根下找 SKILL.md(优先根目录,其次任意一层)。"""
202
+ p = root / "SKILL.md"
203
+ if p.is_file():
204
+ return p
205
+ for candidate in sorted(root.rglob("SKILL.md")):
206
+ parts = candidate.relative_to(root).parts
207
+ if len(parts) <= 2 and "__pycache__" not in parts:
208
+ return candidate
209
+ return None
210
+
211
+
212
+ # ── 规则扫描 ───────────────────────────────────────────────────────────────
213
+
214
+ _COMPILED = {}
215
+
216
+
217
+ def _compile():
218
+ if _COMPILED:
219
+ return _COMPILED
220
+ for r in verify_rules.PATTERN_RULES:
221
+ try:
222
+ _COMPILED[r.id] = re.compile(r.pattern)
223
+ except re.error as e:
224
+ raise ValueError("规则 %s 正则编译失败: %s" % (r.id, e))
225
+ return _COMPILED
226
+
227
+
228
+ _B64_RE = re.compile(r"[A-Za-z0-9+/]{24,}={0,2}")
229
+ _B64_SUSPICIOUS = verify_rules.B64_SUSPICIOUS_WORDS
230
+
231
+
232
+ def _check_base64(line):
233
+ """base64 长串解码后含命令/下载特征 → 编码指令注入提示。"""
234
+ for m in _B64_RE.finditer(line):
235
+ s = m.group(0)
236
+ if len(s) % 4 == 1:
237
+ continue
238
+ try:
239
+ pad = s + "=" * (-len(s) % 4)
240
+ dec = base64.b64decode(pad, validate=False)
241
+ except Exception:
242
+ continue
243
+ try:
244
+ text = dec.decode("utf-8", errors="ignore")
245
+ except Exception:
246
+ continue
247
+ if len(text) < 8:
248
+ continue
249
+ printable = sum(1 for ch in text if 32 <= ord(ch) < 127)
250
+ if printable < len(text) * 0.7:
251
+ continue
252
+ low = text.lower()
253
+ hits = [k for k in _B64_SUSPICIOUS if k in low]
254
+ if len(hits) >= 2:
255
+ return "base64 编码内容含命令/网络特征(%s)" % ", ".join(hits[:3])
256
+ return None
257
+
258
+
259
+ def scan_patterns(files):
260
+ """对文件跑全量规则,返回 Findings 列表。"""
261
+ compiled = _compile()
262
+ findings = []
263
+ seen = set()
264
+ for path, rel in files:
265
+ lines = read_lines(path)
266
+ for idx, line in enumerate(lines, start=1):
267
+ if not line.strip():
268
+ continue
269
+ # base64 编码指令检查(独立启发式,非正则规则)
270
+ hint = _check_base64(line)
271
+ if hint:
272
+ key = ("PIJ-B64", rel, idx)
273
+ if key not in seen:
274
+ seen.add(key)
275
+ findings.append(Finding(
276
+ "PromptInjection", "high", "编码指令",
277
+ rel, idx, hint, 60, "PIJ-B64"))
278
+ for rule in verify_rules.PATTERN_RULES:
279
+ pat = compiled[rule.id]
280
+ try:
281
+ if pat.search(line):
282
+ key = (rule.id, rel, idx)
283
+ if key in seen:
284
+ continue
285
+ seen.add(key)
286
+ findings.append(Finding(
287
+ rule.detector, rule.severity,
288
+ _category_of(rule.detector), rel, idx,
289
+ rule.description, rule.confidence, rule.id))
290
+ except re.error:
291
+ continue
292
+ # 敏感文件名级匹配
293
+ for path, rel in files:
294
+ base = Path(rel).name.lower()
295
+ for fname, desc, sev, conf in verify_rules.SENSITIVE_FILENAMES:
296
+ if base == fname.lower() or (fname.startswith(".") and rel.lower().endswith(fname.lower())):
297
+ key = ("SENS", fname, rel)
298
+ if key not in seen:
299
+ seen.add(key)
300
+ findings.append(Finding(
301
+ "CredentialTheft", sev, "凭据文件",
302
+ rel, 0, "存在敏感文件名: %s(%s)" % (fname, desc),
303
+ conf, "SENS-" + fname.upper()))
304
+ return findings
305
+
306
+
307
+ _CATEGORY_MAP = {
308
+ "DownloadExec": "下载即执行",
309
+ "Obfuscation": "混淆执行",
310
+ "Persistence": "持久化",
311
+ "Exfiltration": "数据外传",
312
+ "CredentialTheft": "凭据窃取",
313
+ "NetworkCall": "网络调用",
314
+ "PrivilegeEscalation": "权限提升",
315
+ "SocialEngineering": "社会工程",
316
+ "PromptInjection": "提示注入",
317
+ }
318
+
319
+
320
+ def _category_of(detector):
321
+ return _CATEGORY_MAP.get(detector, detector)
322
+
323
+
324
+ # ── SKILL.md 完整性 ────────────────────────────────────────────────────────
325
+
326
+ _FM_RE = re.compile(r"^---\s*$")
327
+
328
+
329
+ def parse_frontmatter(text):
330
+ lines = text.split("\n")
331
+ if not lines or not _FM_RE.match(lines[0].strip()):
332
+ return None
333
+ end = None
334
+ for i in range(1, len(lines)):
335
+ if _FM_RE.match(lines[i].strip()):
336
+ end = i
337
+ break
338
+ if end is None:
339
+ return None
340
+ fm = {}
341
+ for line in lines[1:end]:
342
+ if ":" in line:
343
+ k, v = line.split(":", 1)
344
+ fm[k.strip().lower()] = v.strip()
345
+ return fm
346
+
347
+
348
+ def check_skill_integrity(root, findings, name_hint):
349
+ """SKILL.md 完整性检查(结构类,severity low/medium)。"""
350
+ skill_md = find_skill_md(root)
351
+ if skill_md is None:
352
+ findings.append(Finding(
353
+ "Structure", "medium", "技能结构",
354
+ "SKILL.md", 0,
355
+ "未找到 SKILL.md(技能入口缺失)", 80, "STR-001"))
356
+ return
357
+ try:
358
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
359
+ except OSError:
360
+ return
361
+ fm = parse_frontmatter(text)
362
+ if fm is None:
363
+ findings.append(Finding(
364
+ "Structure", "medium", "技能结构",
365
+ str(skill_md), 0,
366
+ "SKILL.md 缺少 YAML frontmatter(--- 开头)", 80, "STR-002"))
367
+ return
368
+ required = {"name", "description"}
369
+ missing = [k for k in required if not fm.get(k)]
370
+ if missing:
371
+ findings.append(Finding(
372
+ "Structure", "medium", "技能结构",
373
+ str(skill_md), 0,
374
+ "SKILL.md frontmatter 缺少字段: %s" % ", ".join(sorted(missing)),
375
+ 80, "STR-003"))
376
+ name = fm.get("name", "")
377
+ if name_hint and name and name != name_hint:
378
+ findings.append(Finding(
379
+ "Structure", "medium", "技能结构",
380
+ str(skill_md), 0,
381
+ "frontmatter name(%s)与目录名(%s)不一致" % (name, name_hint),
382
+ 75, "STR-004"))
383
+ # markdown 围栏平衡
384
+ fences = text.count("```")
385
+ if fences % 2 == 1:
386
+ findings.append(Finding(
387
+ "Structure", "low", "技能结构",
388
+ str(skill_md), 0,
389
+ "markdown 代码围栏数量为奇数(可能截断)", 60, "STR-005"))
390
+ # 占位符 / 未完成标记
391
+ for pat, label in ((r"<\s*技能slug\s*>", "未替换占位符 <技能slug>"),
392
+ (r"TODO|FIXME|TBD|XXX", "未完成标记 TODO/FIXME")):
393
+ if re.search(pat, text):
394
+ findings.append(Finding(
395
+ "Structure", "low", "技能结构",
396
+ str(skill_md), 0, label, 55, "STR-006"))
397
+ # description 触发/边界要素
398
+ desc = fm.get("description", "")
399
+ if desc and not re.search(r"触发|何时|trigger|when", desc, re.I):
400
+ findings.append(Finding(
401
+ "Structure", "low", "技能结构",
402
+ str(skill_md), 0,
403
+ "description 缺少触发条件(触发/何时/when)", 50, "STR-007"))
404
+ if desc and not re.search(r"边界|Do\s*NOT\s*trigger|do not trigger|勿", desc, re.I):
405
+ findings.append(Finding(
406
+ "Structure", "low", "技能结构",
407
+ str(skill_md), 0,
408
+ "description 缺少边界声明(边界/Do NOT trigger)", 50, "STR-008"))
409
+
410
+
411
+ # ── 权限需求分析(info 级汇总;模式定义在 verify_rules.py 签名区)─────────
412
+ _PERM_NET = verify_rules.PERM_NET_RE
413
+ _PERM_EXEC = verify_rules.PERM_EXEC_RE
414
+ _PERM_WRITE = verify_rules.PERM_WRITE_RE
415
+ _PERM_READ_SENS = verify_rules.PERM_READ_SENS_RE
416
+
417
+
418
+ _DETECTOR_SIG_FILES = {"audit_rules.py", "verify_rules.py", "vetter_rules.py", "hardening_rules.py"}
419
+ _DOC_EXT = {".md", ".txt", ".markdown", ".rst"}
420
+
421
+
422
+ 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
428
+
429
+
430
+ def downgrade_detector_docs(findings, root):
431
+ """检测技能文档中的检测模式描述命中 → 降级为 info(固有属性,非实际行为)。
432
+
433
+ 对「安全检测技能」区分『检测能力文档』与『实际行为』。
434
+ 仅降级文档文件(.md/.txt)中的命中;脚本代码命中保持原判级。
435
+ """
436
+ if not is_detector_skill(root):
437
+ return
438
+ for f in findings:
439
+ if f.severity in ("critical", "high", "medium"):
440
+ if Path(f.file_path).suffix.lower() in _DOC_EXT:
441
+ f.severity = "info"
442
+ f.rule_id = (f.rule_id or f.detector) + "-DOC"
443
+ f.description = f.description + "(检测技能文档描述,非实际行为)"
444
+ f.confidence = 30
445
+
446
+
447
+ def permission_summary(files, findings):
448
+ """扫描脚本中声明的权限需求(info 级提示,不入 verdict 决策)。"""
449
+ hits = {"网络调用": 0, "命令执行": 0, "文件写入": 0, "读取敏感文件": 0}
450
+ for path, rel in files:
451
+ if not path.suffix.lower() in TEXT_EXTENSIONS and path.name not in DOTFILE_NAMES:
452
+ continue
453
+ for line in read_lines(path):
454
+ if _PERM_NET.search(line):
455
+ hits["网络调用"] += 1
456
+ if _PERM_EXEC.search(line):
457
+ hits["命令执行"] += 1
458
+ if _PERM_WRITE.search(line):
459
+ hits["文件写入"] += 1
460
+ if _PERM_READ_SENS.search(line):
461
+ hits["读取敏感文件"] += 1
462
+ for label, count in hits.items():
463
+ if count:
464
+ findings.append(Finding(
465
+ "Permission", "info", "权限需求",
466
+ "SUMMARY", 0,
467
+ "%s:命中 %d 处(仅供人工评估权限范围)" % (label, count),
468
+ 40, "PERM"))
469
+
470
+
471
+ # ── verdict / 统计 ─────────────────────────────────────────────────────────
472
+
473
+ def summarize(findings):
474
+ counts = {sev: 0 for sev in _SEVERITY_ORDER}
475
+ by_severity = {}
476
+ for f in findings:
477
+ counts[f.severity] = counts.get(f.severity, 0) + 1
478
+ by_severity.setdefault(f.severity, []).append(f)
479
+ highest = None
480
+ for sev in ("critical", "high", "medium", "low", "info"):
481
+ if counts.get(sev, 0):
482
+ highest = sev
483
+ break
484
+ verdict = VERDICT_BY_SEVERITY.get(highest, VERDICT_SAFE)
485
+ return counts, by_severity, highest, verdict
486
+
487
+
488
+ def exit_code_of(verdict):
489
+ return VERDICT_EXIT.get(verdict, 4)
490
+
491
+
492
+ # ── 扫描主流程 ─────────────────────────────────────────────────────────────
493
+
494
+ def _safe_extract(tf, dest):
495
+ """提取 tarball(Python 3.8 兼容;手工路径穿越防护)。"""
496
+ for member in tf.getmembers():
497
+ name = member.name
498
+ if name.startswith(("/", "\\")) or ".." in Path(name).parts:
499
+ raise ValueError("tarball 含危险路径: %s" % name)
500
+ tf.extractall(dest)
501
+
502
+
503
+ def scan_core(target, name_hint=None):
504
+ """扫描目录/tarball,返回 (findings, counts, verdict, scan_meta)。"""
505
+ tmpdir = None
506
+ root = Path(target)
507
+ if root.is_file() and str(root).lower().endswith((".tgz", ".tar.gz")):
508
+ tmpdir = tempfile.mkdtemp(prefix="yotta-verify-")
509
+ with tarfile.open(str(root), "r:gz") as tf:
510
+ _safe_extract(tf, tmpdir)
511
+ root = Path(tmpdir)
512
+ if not root.is_dir():
513
+ if tmpdir:
514
+ import shutil
515
+ shutil.rmtree(tmpdir, ignore_errors=True)
516
+ raise SystemExit("目标不存在或不是目录: %s" % target)
517
+ files = walk_files(root)
518
+ findings = scan_patterns(files)
519
+ check_skill_integrity(root, findings, name_hint)
520
+ permission_summary(files, findings)
521
+ # 检测技能文档降级(2026-08-30):目标含检测器签名文件(audit_rules 等)→
522
+ # 文档中命中「检测能力描述」(注入模式/凭据等字面)属固有属性,降级为 info,非实际行为。
523
+ downgrade_detector_docs(findings, root)
524
+ # L2/L3 威胁捕获引擎(2026-08-30 增强:数据流 + MCP 工具面)
525
+ for fd in threat_engine.analyze_mcp_tool_surface(files, read_lines):
526
+ findings.append(Finding(
527
+ fd["detector"], fd["severity"], fd["category"], fd["file"],
528
+ fd["line"], fd["description"], fd["confidence"], fd["rule_id"]))
529
+ for fd in threat_engine.analyze_dataflow(files, read_lines):
530
+ findings.append(Finding(
531
+ fd["detector"], fd["severity"], fd["category"], fd["file"],
532
+ fd["line"], fd["description"], fd["confidence"], fd["rule_id"]))
533
+ counts, by_severity, highest, verdict = summarize(findings)
534
+ meta = {
535
+ "target": str(target),
536
+ "files_scanned": len(files),
537
+ "scanned_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
538
+ "content_hash": threat_engine.build_content_hash(files, read_lines),
539
+ "engine": "yotta-verify v%s + threat_engine" % VERSION,
540
+ }
541
+ if tmpdir:
542
+ import shutil
543
+ shutil.rmtree(tmpdir, ignore_errors=True)
544
+ return findings, counts, verdict, meta
545
+
546
+
547
+ # ── 报告渲染 ───────────────────────────────────────────────────────────────
548
+
549
+ def render_text(findings, counts, verdict, meta, tool_version=VERSION):
550
+ fdicts = [f.to_dict() for f in findings]
551
+ lines = []
552
+ lines.append("%s %s v%s —— 装前安全扫描" % (CN_NAME, TOOL_NAME, tool_version))
553
+ lines.append("目标:%s(扫描 %d 个文件)" % (meta["target"], meta["files_scanned"]))
554
+ lines.append("")
555
+ lines.append("verdict: %s" % verdict)
556
+ parts = ["%s %d" % (k, counts.get(k, 0)) for k in ("critical", "high", "medium", "low", "info")]
557
+ lines.append("发现:%s" % " / ".join(parts))
558
+ lines.append("安全健康度评分:%d/100" % threat_engine.health_score(fdicts))
559
+ lines.append("")
560
+ lines.append("威胁捕获模型(8 类):")
561
+ tv = threat_engine.taxonomy_view(
562
+ fdicts, verify_rules.THREAT_TAXONOMY, verify_rules.TAXONOMY_ORDER,
563
+ verify_rules.DETECTOR_TO_TAXONOMY)
564
+ for key in verify_rules.TAXONOMY_ORDER:
565
+ v = tv[key]
566
+ lines.append(" %-16s %-11s %d" % (v["name"], v["verdict"], v["count"]))
567
+ lines.append("")
568
+ lines.append("行为项(13 项):")
569
+ bv = threat_engine.behavior_view(
570
+ fdicts, verify_rules.BEHAVIORS, verify_rules.DETECTOR_TO_BEHAVIORS)
571
+ observed = [b["behavior"] for b in bv if b["observed"]]
572
+ lines.append(" %s" % ("、".join(observed) if observed else "未观察到明显系统行为"))
573
+ lines.append("")
574
+ if findings:
575
+ by = {}
576
+ for f in sorted(findings, key=lambda x: _SEVERITY_ORDER.index(x.severity) if x.severity in _SEVERITY_ORDER else 0):
577
+ by.setdefault(f.severity, []).append(f)
578
+ for sev in _SEVERITY_ORDER:
579
+ items = by.get(sev, [])
580
+ if not items:
581
+ continue
582
+ lines.append("[%s] %d" % (sev.upper(), len(items)))
583
+ for f in items[:15]:
584
+ loc = "%s:%s" % (f.file_path, f.line) if f.line else f.file_path
585
+ lines.append(" %-12s %-6s %s(%s,置信 %d%%)"
586
+ % (f.rule_id or f.detector, f.severity, f.description, loc, f.confidence))
587
+ if len(items) > 15:
588
+ lines.append(" … 其余 %d 条(见 --json / --report)" % (len(items) - 15))
589
+ else:
590
+ lines.append("未发现任何可疑项。")
591
+ lines.append("")
592
+ lines.append("提示:verdict 仅供人工决策参考,请结合元安(深度扫描)/ 元审(四阶段审查)复核。")
593
+ return "\n".join(lines)
594
+
595
+
596
+ def render_json(findings, counts, verdict, meta, tool_version=VERSION):
597
+ fdicts = [f.to_dict() for f in findings]
598
+ return json.dumps({
599
+ "tool": {"name": TOOL_NAME, "cn": CN_NAME, "version": tool_version},
600
+ "meta": meta,
601
+ "verdict": verdict,
602
+ "counts": counts,
603
+ "findings": [f.to_dict() for f in findings],
604
+ "threat": {
605
+ "health_score": threat_engine.health_score(fdicts),
606
+ "taxonomy": threat_engine.taxonomy_view(
607
+ fdicts, verify_rules.THREAT_TAXONOMY, verify_rules.TAXONOMY_ORDER,
608
+ verify_rules.DETECTOR_TO_TAXONOMY),
609
+ "behaviors": threat_engine.behavior_view(
610
+ fdicts, verify_rules.BEHAVIORS, verify_rules.DETECTOR_TO_BEHAVIORS),
611
+ "files": threat_engine.file_view(fdicts),
612
+ "repair_guide": threat_engine.repair_guide(fdicts),
613
+ },
614
+ }, ensure_ascii=False, indent=2)
615
+
616
+
617
+ def render_markdown(findings, counts, verdict, meta, tool_version=VERSION):
618
+ fdicts = [f.to_dict() for f in findings]
619
+ lines = []
620
+ lines.append("# SKILL VERIFY REPORT")
621
+ lines.append("")
622
+ lines.append("- 工具:%s %s v%s" % (CN_NAME, TOOL_NAME, tool_version))
623
+ lines.append("- 目标:%s(扫描 %d 个文件)" % (meta["target"], meta["files_scanned"]))
624
+ lines.append("- 扫描时间:%s" % meta["scanned_at"])
625
+ lines.append("")
626
+ lines.append("## Verdict")
627
+ lines.append("")
628
+ lines.append("**%s**" % verdict)
629
+ lines.append("")
630
+ lines.append("| 严重级 | 数量 |")
631
+ lines.append("|---|---|")
632
+ for sev in ("critical", "high", "medium", "low", "info"):
633
+ lines.append("| %s | %d |" % (sev, counts.get(sev, 0)))
634
+ lines.append("")
635
+ lines.append("**安全健康度评分:%d/100**" % threat_engine.health_score(fdicts))
636
+ lines.append("")
637
+ lines.append("## 威胁捕获模型视图(8 类)")
638
+ lines.append("")
639
+ lines.append("| 检测点 | verdict | 命中 |")
640
+ lines.append("|---|---|---|")
641
+ tv = threat_engine.taxonomy_view(
642
+ fdicts, verify_rules.THREAT_TAXONOMY, verify_rules.TAXONOMY_ORDER,
643
+ verify_rules.DETECTOR_TO_TAXONOMY)
644
+ for key in verify_rules.TAXONOMY_ORDER:
645
+ v = tv[key]
646
+ lines.append("| %s | %s | %d |" % (v["name"], v["verdict"], v["count"]))
647
+ lines.append("")
648
+ lines.append("## 行为项(13 项)")
649
+ lines.append("")
650
+ bv = threat_engine.behavior_view(
651
+ fdicts, verify_rules.BEHAVIORS, verify_rules.DETECTOR_TO_BEHAVIORS)
652
+ observed = [b["behavior"] for b in bv if b["observed"]]
653
+ lines.append("观察到:%s" % ("、".join(observed) if observed else "未观察到明显系统行为"))
654
+ lines.append("")
655
+ guide = threat_engine.repair_guide(fdicts)
656
+ if guide:
657
+ lines.append("## 修复建议指南")
658
+ lines.append("")
659
+ for i, g in enumerate(guide, 1):
660
+ lines.append("%d. %s" % (i, g))
661
+ lines.append("")
662
+ lines.append("## Findings")
663
+ lines.append("")
664
+ if not findings:
665
+ lines.append("未发现任何可疑项。")
666
+ else:
667
+ lines.append("| 规则 | 严重级 | 位置 | 说明 | 置信度 |")
668
+ lines.append("|---|---|---|---|---|")
669
+ for f in sorted(findings, key=lambda x: _SEVERITY_ORDER.index(x.severity) if x.severity in _SEVERITY_ORDER else 0):
670
+ loc = "%s:%s" % (f.file_path, f.line) if f.line else f.file_path
671
+ lines.append("| %s | %s | %s | %s | %d%% |"
672
+ % (f.rule_id or f.detector, f.severity, loc, f.description, f.confidence))
673
+ lines.append("")
674
+ lines.append("> 结论仅供人工决策参考;最终判断由用户。建议结合元安(深度扫描)与元审(四阶段审查)。")
675
+ return "\n".join(lines)
676
+
677
+
678
+ # ── 徽章生成(零依赖 SVG,shields.io flat 风格)──────────────────────────
679
+
680
+ def _text_width(text):
681
+ # 近似宽度:ASCII ~7px,CJK ~11px(font-size 11px)
682
+ w = 0
683
+ for ch in text:
684
+ w += 11 if ord(ch) > 0x2E7F else 7
685
+ return w
686
+
687
+
688
+ def _seg_width(label, value):
689
+ return 10 + _text_width(label) + 10 + _text_width(value) + 6
690
+
691
+
692
+ def badge_svg(segments, height=20):
693
+ """segments: [(label, value, color)] → 扁平徽章 SVG(shields.io 风格)。"""
694
+ widths = [_seg_width(l, v) for l, v, _ in segments]
695
+ total = sum(widths)
696
+ pad = 4
697
+ x = pad
698
+ parts = []
699
+ parts.append('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d">'
700
+ % (total + pad * 2, height))
701
+ for (label, value, color), w in zip(segments, widths):
702
+ lw = 10 + _text_width(label)
703
+ vw = 10 + _text_width(value) + 6
704
+ # 标签段
705
+ parts.append('<rect x="%d" y="0" width="%d" height="%d" fill="#555" rx="3"/>' % (x, lw, height))
706
+ # 值段
707
+ parts.append('<rect x="%d" y="0" width="%d" height="%d" fill="%s" rx="3"/>'
708
+ % (x + lw - 3, vw + 3, height, color))
709
+ # 文本
710
+ parts.append('<text x="%d" y="%d" fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" font-weight="bold">%s</text>'
711
+ % (x + 5, height - 6, _xml(label)))
712
+ parts.append('<text x="%d" y="%d" fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" font-weight="bold">%s</text>'
713
+ % (x + lw + 3, height - 6, _xml(value)))
714
+ x += w
715
+ parts.append('</svg>')
716
+ return "".join(parts)
717
+
718
+
719
+ def _xml(text):
720
+ return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
721
+
722
+
723
+ def build_badges(verdict, extra=None):
724
+ """构造徽章 SVG + shields.io URL。extra: {validate, vetter, audit, version, tests}"""
725
+ extra = extra or {}
726
+ color = BADGE_COLORS.get(verdict, "lightgrey")
727
+ url_label = "verified"
728
+ url_value = verdict.replace(" ", "%20")
729
+ url = "https://img.shields.io/badge/%s-%s-%s" % (url_label, url_value, color)
730
+ segs = [("verified", verdict, color)]
731
+ if extra.get("validate") is not None:
732
+ segs.append(("validate-skill", extra["validate"], "3c8" if extra["validate"].upper() == "PASS" else "e05d44"))
733
+ if extra.get("vetter") is not None:
734
+ segs.append(("vetter", extra["vetter"], BADGE_COLORS.get(extra["vetter"], "9f9f9f")))
735
+ if extra.get("audit") is not None:
736
+ segs.append(("audit", extra["audit"], BADGE_COLORS.get(extra["audit"], "9f9f9f")))
737
+ if extra.get("version"):
738
+ segs.append(("version", extra["version"], "007ec6"))
739
+ if extra.get("tests") is not None:
740
+ segs.append(("tests", str(extra["tests"]), "007ec6"))
741
+ return badge_svg(segs), url
742
+
743
+
744
+ def shields_url(verdict):
745
+ color = BADGE_COLORS.get(verdict, "lightgrey")
746
+ return "https://img.shields.io/badge/verified-%s-%s" % (verdict.replace(" ", "%20"), color)
747
+
748
+
749
+ # ── CLI ─────────────────────────────────────────────────────────────────────
750
+
751
+ def _name_hint(target):
752
+ return Path(target).name
753
+
754
+
755
+ def cmd_scan(args):
756
+ findings, counts, verdict, meta = scan_core(args.path, name_hint=_name_hint(args.path))
757
+ code = exit_code_of(verdict)
758
+ # gate 模式
759
+ if args.max_severity:
760
+ limit = _SEVERITY_VALUE.get(args.max_severity.lower(), 1)
761
+ worst = _SEVERITY_VALUE.get(verdict_worst(findings), 0)
762
+ if worst > limit:
763
+ print("gate 失败:最大严重级 %s 超过阈值 %s" % (verdict_worst(findings), args.max_severity))
764
+ code = max(code, 1)
765
+ if args.json:
766
+ print(render_json(findings, counts, verdict, meta))
767
+ else:
768
+ print(render_text(findings, counts, verdict, meta))
769
+ if args.report:
770
+ Path(args.report).write_text(render_markdown(findings, counts, verdict, meta),
771
+ encoding="utf-8")
772
+ print("\n报告已写入: %s" % args.report)
773
+ if args.badge:
774
+ extra = {"validate": "PASS" if code <= 1 else "FAIL",
775
+ "version": VERSION,
776
+ "tests": None}
777
+ svg, url = build_badges(verdict, extra)
778
+ out = args.badge if isinstance(args.badge, str) else "assets/audited.svg"
779
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
780
+ Path(out).write_text(svg, encoding="utf-8")
781
+ print("audited 徽章已生成: %s" % out)
782
+ print("shields.io: %s" % url)
783
+ return code
784
+
785
+
786
+ def verdict_worst(findings):
787
+ worst = "info"
788
+ for f in findings:
789
+ if _SEVERITY_VALUE.get(f.severity, 0) > _SEVERITY_VALUE.get(worst, 0):
790
+ worst = f.severity
791
+ return worst
792
+
793
+
794
+ def cmd_badge(args):
795
+ extra = {
796
+ "validate": getattr(args, "validate_skill", None),
797
+ "vetter": getattr(args, "vetter_verdict", None),
798
+ "audit": getattr(args, "audit_verdict", None),
799
+ "version": getattr(args, "version", None) or VERSION,
800
+ "tests": getattr(args, "tests", None),
801
+ }
802
+ # 若给目录:先扫描拿 verdict;否则默认 SAFE
803
+ if args.path and Path(args.path).exists():
804
+ findings, counts, verdict, meta = scan_core(args.path, name_hint=_name_hint(args.path))
805
+ else:
806
+ verdict = VERDICT_SAFE
807
+ counts = {s: 0 for s in _SEVERITY_ORDER}
808
+ svg, url = build_badges(verdict, extra)
809
+ out = args.out or "assets/audited.svg"
810
+ Path(out).parent.mkdir(parents=True, exist_ok=True)
811
+ Path(out).write_text(svg, encoding="utf-8")
812
+ print("audited 徽章已生成: %s" % out)
813
+ print("shields.io: %s" % url)
814
+ return 0
815
+
816
+
817
+ def cmd_report(args):
818
+ findings, counts, verdict, meta = scan_core(args.path, name_hint=_name_hint(args.path))
819
+ if args.json:
820
+ print(render_json(findings, counts, verdict, meta))
821
+ else:
822
+ print(render_markdown(findings, counts, verdict, meta))
823
+ if args.out:
824
+ Path(args.out).write_text(
825
+ render_json(findings, counts, verdict, meta) if args.json
826
+ else render_markdown(findings, counts, verdict, meta),
827
+ encoding="utf-8")
828
+ print("报告已写入: %s" % args.out)
829
+ return exit_code_of(verdict)
830
+
831
+
832
+ def cmd_gate(args):
833
+ findings, counts, verdict, meta = scan_core(args.path, name_hint=_name_hint(args.path))
834
+ code = exit_code_of(verdict)
835
+ limit = _SEVERITY_VALUE.get((args.max_severity or "medium").lower(), 1)
836
+ worst = _SEVERITY_VALUE.get(verdict_worst(findings), 0)
837
+ if args.json:
838
+ print(render_json(findings, counts, verdict, meta))
839
+ else:
840
+ print(render_text(findings, counts, verdict, meta))
841
+ if worst > limit:
842
+ print("gate 失败:最严重级 %s 超过阈值 %s(exit %d)" % (verdict_worst(findings), args.max_severity, max(code, 1)))
843
+ return max(code, 1)
844
+ print("gate 通过:最严重级 %s ≤ 阈值 %s" % (verdict_worst(findings), args.max_severity))
845
+ return code
846
+
847
+
848
+ def main(argv=None):
849
+ parser = argparse.ArgumentParser(
850
+ prog=TOOL_NAME,
851
+ description="%s %s —— 装前安全扫描器(确定性静态校验 + audited 徽章)" % (CN_NAME, TOOL_NAME))
852
+ parser.add_argument("--version", action="store_true", help="显示版本")
853
+ sub = parser.add_subparsers(dest="command")
854
+
855
+ p_scan = sub.add_parser("scan", help="装前安全扫描(prompt injection + 危险模式 + SKILL 完整性)")
856
+ p_scan.add_argument("path")
857
+ p_scan.add_argument("--json", action="store_true")
858
+ p_scan.add_argument("--report")
859
+ p_scan.add_argument("--badge", nargs="?", const="assets/audited.svg", default=None)
860
+ p_scan.add_argument("--max-severity")
861
+ p_scan.set_defaults(func=cmd_scan)
862
+
863
+ p_badge = sub.add_parser("badge", help="生成 audited 徽章(本地 SVG + shields.io URL)")
864
+ p_badge.add_argument("path", nargs="?", default=None)
865
+ p_badge.add_argument("--out")
866
+ p_badge.add_argument("--validate-skill", choices=["pass", "fail"])
867
+ p_badge.add_argument("--vetter-verdict")
868
+ p_badge.add_argument("--audit-verdict")
869
+ p_badge.add_argument("--tests", type=int)
870
+ p_badge.add_argument("--version")
871
+ p_badge.set_defaults(func=cmd_badge)
872
+
873
+ p_report = sub.add_parser("report", help="生成验证报告(Markdown / JSON)")
874
+ p_report.add_argument("path")
875
+ p_report.add_argument("--json", action="store_true")
876
+ p_report.add_argument("--out")
877
+ p_report.set_defaults(func=cmd_report)
878
+
879
+ p_gate = sub.add_parser("gate", help="CI 闸门(默认阈值 medium,超出即失败)")
880
+ p_gate.add_argument("path")
881
+ p_gate.add_argument("--max-severity", default="medium")
882
+ p_gate.add_argument("--json", action="store_true")
883
+ p_gate.set_defaults(func=cmd_gate)
884
+
885
+ args = parser.parse_args(argv)
886
+ if args.version and not getattr(args, "command", None):
887
+ print("%s %s v%s" % (CN_NAME, TOOL_NAME, VERSION))
888
+ return 0
889
+ if not getattr(args, "command", None):
890
+ parser.print_help()
891
+ return 4
892
+ try:
893
+ return args.func(args)
894
+ except SystemExit as e:
895
+ return e.code if isinstance(e.code, int) else 4
896
+ except Exception as e: # noqa: BLE001
897
+ print("错误:%s" % e, file=sys.stderr)
898
+ return 4
899
+
900
+
901
+ if __name__ == "__main__":
902
+ sys.exit(main())