@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,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.2.3"
|
|
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("&", "&").replace("<", "<").replace(">", ">"))
|
|
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())
|