@yottameta/yotta-vetter 0.1.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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/NOTICE +11 -0
- package/README.md +64 -0
- package/SKILL.md +76 -0
- package/bin/install.js +163 -0
- package/install.sh +132 -0
- package/package.json +35 -0
- package/references/checklist.md +43 -0
- package/references/vetting-report-template.md +41 -0
- package/scripts/test_yotta_vetter.py +111 -0
- package/scripts/vetter_rules.py +261 -0
- package/scripts/yotta_vetter.py +576 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""yotta_vetter.py — YottaMeta 元审(yotta-vetter)技能审查 checker。
|
|
4
|
+
|
|
5
|
+
安装任何技能前的「安全审查协议」:
|
|
6
|
+
check 四阶段初审(来源/代码/权限/风险)+ 危险模式规则扫描
|
|
7
|
+
source 来源半自动化检查(GitHub 仓库元数据,本地缓存,无网络自动降级)
|
|
8
|
+
|
|
9
|
+
- 危险模式规则与 yotta-security-audit(元安)共用(scripts/vetter_rules.py
|
|
10
|
+
为 audit_rules.py 的同步副本,勿手改)。
|
|
11
|
+
- 初审发现可疑(high 及以上)会输出一条命令引导跑元安深度扫描(联动 V3)。
|
|
12
|
+
- 纯 Python 3.8+ 标准库,Windows + Linux 通用。
|
|
13
|
+
|
|
14
|
+
exit code 语义(与元安一致):
|
|
15
|
+
0 = 干净 / 仅有 low 提示
|
|
16
|
+
1 = 存在 medium
|
|
17
|
+
2 = 存在 high
|
|
18
|
+
3 = 存在 critical
|
|
19
|
+
4 = 用法错误/致命异常
|
|
20
|
+
|
|
21
|
+
用法示例:
|
|
22
|
+
python3 yotta_vetter.py check ./some-skill
|
|
23
|
+
python3 yotta_vetter.py check ./some-skill --json --report report.md
|
|
24
|
+
python3 yotta_vetter.py source github:YottaMeta/yotta-memory
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
try:
|
|
43
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
_HERE = Path(__file__).resolve().parent
|
|
48
|
+
sys.path.insert(0, str(_HERE))
|
|
49
|
+
import vetter_rules # noqa: E402
|
|
50
|
+
|
|
51
|
+
VERSION = "0.1.0"
|
|
52
|
+
TOOL_NAME = "yotta-vetter"
|
|
53
|
+
MAX_FILE_SIZE = 1_000_000
|
|
54
|
+
MAX_LINE_LEN = vetter_rules.MAX_LINE_LEN
|
|
55
|
+
SKIP_DIRS = {"venv", "node_modules", ".git", "__pycache__", ".mypy_cache",
|
|
56
|
+
".tox", "dist", "build", ".egg-info", ".venv", ".idea", ".vscode"}
|
|
57
|
+
TEXT_EXTENSIONS = {
|
|
58
|
+
".py", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".sh", ".bash", ".zsh",
|
|
59
|
+
".md", ".txt", ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg",
|
|
60
|
+
".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
|
|
61
|
+
".html", ".css", ".xml", ".svg", ".plist", ".ps1", ".bat", ".cmd",
|
|
62
|
+
".env", ".conf", ".properties", ".gradle",
|
|
63
|
+
}
|
|
64
|
+
DOTFILE_NAMES = {".env", ".env.example", ".netrc", ".pgpass", ".bashrc",
|
|
65
|
+
".zshrc", ".profile", ".bash_profile", ".npmrc", ".gitconfig"}
|
|
66
|
+
SCRIPT_EXTENSIONS = {".py", ".js", ".ts", ".mjs", ".cjs", ".sh", ".bash", ".zsh",
|
|
67
|
+
".ps1", ".bat", ".cmd", ".rb", ".pl"}
|
|
68
|
+
|
|
69
|
+
_SEVERITY_RANK = {"info": 0, "low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
70
|
+
_SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
|
71
|
+
|
|
72
|
+
# ── 基础工具 ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
class Finding:
|
|
75
|
+
__slots__ = ("detector", "severity", "category", "file_path", "line",
|
|
76
|
+
"description", "confidence", "rule_id")
|
|
77
|
+
|
|
78
|
+
def __init__(self, detector, severity, category, file_path, line=0,
|
|
79
|
+
description="", confidence=50, rule_id=""):
|
|
80
|
+
self.detector = detector
|
|
81
|
+
self.severity = severity
|
|
82
|
+
self.category = category
|
|
83
|
+
self.file_path = file_path
|
|
84
|
+
self.line = line
|
|
85
|
+
self.description = description
|
|
86
|
+
self.confidence = confidence
|
|
87
|
+
self.rule_id = rule_id
|
|
88
|
+
|
|
89
|
+
def to_dict(self):
|
|
90
|
+
return {"detector": self.detector, "severity": self.severity,
|
|
91
|
+
"category": self.category, "file": self.file_path,
|
|
92
|
+
"line": self.line, "description": self.description,
|
|
93
|
+
"confidence": self.confidence, "rule_id": self.rule_id}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sev_value(sev):
|
|
97
|
+
return _SEVERITY_RANK.get(sev, 0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _worst(findings):
|
|
101
|
+
worst = "info"
|
|
102
|
+
for f in findings:
|
|
103
|
+
if _sev_value(f.severity) > _sev_value(worst):
|
|
104
|
+
worst = f.severity
|
|
105
|
+
return worst
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_text(p):
|
|
109
|
+
try:
|
|
110
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
111
|
+
except OSError:
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_binary(head):
|
|
116
|
+
return b"\x00" in head[:8192]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def collect_files(root):
|
|
120
|
+
files = []
|
|
121
|
+
for dirpath, dirnames, filenames in os.walk(str(root)):
|
|
122
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
123
|
+
for fname in sorted(filenames):
|
|
124
|
+
p = Path(dirpath) / fname
|
|
125
|
+
try:
|
|
126
|
+
if p.suffix.lower() not in TEXT_EXTENSIONS and p.name.lower() not in DOTFILE_NAMES:
|
|
127
|
+
continue
|
|
128
|
+
if p.name in ("audit_rules.py", "vetter_rules.py"):
|
|
129
|
+
continue # 签名数据文件(规则表)
|
|
130
|
+
if p.stat().st_size > MAX_FILE_SIZE:
|
|
131
|
+
continue
|
|
132
|
+
except OSError:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
with open(p, "rb") as fh:
|
|
136
|
+
if _is_binary(fh.read(8192)):
|
|
137
|
+
continue
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
files.append(p)
|
|
141
|
+
return files
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── 四阶段初审 ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def inventory_checks(root, files):
|
|
147
|
+
"""V2 阶段:结构/权限/风险清单。返回 [Finding]。"""
|
|
148
|
+
findings = []
|
|
149
|
+
root = Path(root)
|
|
150
|
+
skill_md = root / "SKILL.md"
|
|
151
|
+
if not skill_md.is_file():
|
|
152
|
+
findings.append(Finding("Inventory", "medium", "structure", str(skill_md),
|
|
153
|
+
description="缺少 SKILL.md(技能入口文件)", confidence=70,
|
|
154
|
+
rule_id="INV-001"))
|
|
155
|
+
else:
|
|
156
|
+
text = _read_text(skill_md)
|
|
157
|
+
m = re.match(r"^---\s*\n(.*?)\n---", text, re.S)
|
|
158
|
+
if not m:
|
|
159
|
+
findings.append(Finding("Inventory", "medium", "structure", str(skill_md),
|
|
160
|
+
description="SKILL.md 缺少 YAML frontmatter",
|
|
161
|
+
confidence=70, rule_id="INV-002"))
|
|
162
|
+
else:
|
|
163
|
+
fm = m.group(1)
|
|
164
|
+
if not re.search(r"^name:", fm, re.M):
|
|
165
|
+
findings.append(Finding("Inventory", "medium", "structure", str(skill_md),
|
|
166
|
+
description="frontmatter 缺少 name", confidence=60,
|
|
167
|
+
rule_id="INV-003"))
|
|
168
|
+
nm = re.search(r"^name:\s*(.+)$", fm, re.M)
|
|
169
|
+
if nm and nm.group(1).strip() != root.name:
|
|
170
|
+
findings.append(Finding(
|
|
171
|
+
"Inventory", "medium", "structure", str(skill_md),
|
|
172
|
+
description="frontmatter name(%s)与目录名(%s)不一致"
|
|
173
|
+
% (nm.group(1).strip(), root.name),
|
|
174
|
+
confidence=70, rule_id="INV-004"))
|
|
175
|
+
if not (root / "README.md").is_file():
|
|
176
|
+
findings.append(Finding("Inventory", "info", "structure", str(root),
|
|
177
|
+
description="缺少 README.md(可读性提示)",
|
|
178
|
+
confidence=30, rule_id="INV-005"))
|
|
179
|
+
scripts = [p for p in files if p.suffix.lower() in SCRIPT_EXTENSIONS]
|
|
180
|
+
if scripts:
|
|
181
|
+
findings.append(Finding(
|
|
182
|
+
"Inventory", "info", "permissions", str(root),
|
|
183
|
+
description="含可执行脚本 %d 个" % len(scripts), confidence=30,
|
|
184
|
+
rule_id="INV-006"))
|
|
185
|
+
# 权限:Unix 下可执行/全局可写文件
|
|
186
|
+
if os.name != "nt":
|
|
187
|
+
for p in files:
|
|
188
|
+
try:
|
|
189
|
+
mode = p.stat().st_mode
|
|
190
|
+
except OSError:
|
|
191
|
+
continue
|
|
192
|
+
if mode & 0o002:
|
|
193
|
+
findings.append(Finding(
|
|
194
|
+
"Inventory", "medium", "permissions", str(p),
|
|
195
|
+
description="全局可写文件", confidence=70, rule_id="PERM-001"))
|
|
196
|
+
if mode & 0o111 and p.suffix.lower() not in SCRIPT_EXTENSIONS:
|
|
197
|
+
findings.append(Finding(
|
|
198
|
+
"Inventory", "low", "permissions", str(p),
|
|
199
|
+
description="非脚本文件带可执行位", confidence=40,
|
|
200
|
+
rule_id="PERM-002"))
|
|
201
|
+
# 符号链接(可能指向外部)
|
|
202
|
+
for p in files:
|
|
203
|
+
if p.is_symlink():
|
|
204
|
+
findings.append(Finding("Inventory", "medium", "permissions", str(p),
|
|
205
|
+
description="符号链接(指向 %s)" % os.readlink(str(p)),
|
|
206
|
+
confidence=60, rule_id="SYMLINK-001"))
|
|
207
|
+
return findings
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def pattern_scan(files):
|
|
211
|
+
"""V1:用共享规则表扫危险模式。返回 [Finding]。"""
|
|
212
|
+
findings = []
|
|
213
|
+
compiled = vetter_rules.compile_rules()
|
|
214
|
+
for p in files:
|
|
215
|
+
content = _read_text(p)
|
|
216
|
+
if not content:
|
|
217
|
+
continue
|
|
218
|
+
for lineno, raw_line in enumerate(content.splitlines(), 1):
|
|
219
|
+
if len(raw_line) > MAX_LINE_LEN:
|
|
220
|
+
raw_line = raw_line[:MAX_LINE_LEN]
|
|
221
|
+
for rule in vetter_rules.PATTERN_RULES:
|
|
222
|
+
cre = compiled[rule.id]
|
|
223
|
+
if cre.search(raw_line):
|
|
224
|
+
findings.append(Finding(
|
|
225
|
+
detector=rule.detector, severity=rule.severity,
|
|
226
|
+
category=rule.detector.lower(), file_path=str(p),
|
|
227
|
+
line=lineno, description=rule.description,
|
|
228
|
+
confidence=rule.confidence, rule_id=rule.id))
|
|
229
|
+
base = p.name.lower()
|
|
230
|
+
for pat, desc, sev, conf in vetter_rules.SENSITIVE_FILENAMES:
|
|
231
|
+
if pat.lower() in base:
|
|
232
|
+
findings.append(Finding(
|
|
233
|
+
detector="CredentialTheft", severity=sev,
|
|
234
|
+
category="credential_theft", file_path=str(p),
|
|
235
|
+
description="敏感凭据文件命名: %s" % desc,
|
|
236
|
+
confidence=conf, rule_id="FIL-SENS"))
|
|
237
|
+
for rule in vetter_rules.get_rules("SocialEngineering"):
|
|
238
|
+
if compiled[rule.id].search(base):
|
|
239
|
+
findings.append(Finding(
|
|
240
|
+
detector="SocialEngineering", severity=rule.severity,
|
|
241
|
+
category="social_engineering", file_path=str(p),
|
|
242
|
+
description=rule.description + "(文件名)",
|
|
243
|
+
confidence=rule.confidence, rule_id=rule.id))
|
|
244
|
+
return findings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def dedup(findings):
|
|
248
|
+
seen = set()
|
|
249
|
+
out = []
|
|
250
|
+
for f in findings:
|
|
251
|
+
key = (f.file_path, f.line, f.rule_id or f.detector)
|
|
252
|
+
if key in seen:
|
|
253
|
+
continue
|
|
254
|
+
seen.add(key)
|
|
255
|
+
out.append(f)
|
|
256
|
+
return out
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def verdict_for(worst):
|
|
260
|
+
if _sev_value(worst) >= 3:
|
|
261
|
+
return "DO NOT INSTALL", "发现 critical 级风险,拒绝安装并人工复核"
|
|
262
|
+
if _sev_value(worst) >= 2:
|
|
263
|
+
return "INSTALL WITH CAUTION", "发现 high 级风险,需人工复核后决定"
|
|
264
|
+
if _sev_value(worst) >= 1:
|
|
265
|
+
return "REVIEW REQUIRED", "发现 medium 级风险,建议复核后安装"
|
|
266
|
+
return "SAFE TO INSTALL", "未发现明显风险(仍建议按协议完整审查)"
|
|
267
|
+
|
|
268
|
+
# ── 输出(文本 / JSON / 报告)──────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
def fmt_report(findings, scope):
|
|
271
|
+
lines = []
|
|
272
|
+
lines.append("=" * 66)
|
|
273
|
+
lines.append("SKILL VETTING REPORT · %s %s" % (TOOL_NAME, VERSION))
|
|
274
|
+
lines.append("=" * 66)
|
|
275
|
+
lines.append("技能: %s" % scope.get("skill", "-"))
|
|
276
|
+
lines.append("路径: %s" % scope.get("path", "-"))
|
|
277
|
+
lines.append("来源: %s" % scope.get("source", "-"))
|
|
278
|
+
lines.append("审查时间: %s" % scope.get("reviewed_at", ""))
|
|
279
|
+
lines.append("审查者: %s" % scope.get("reviewer", "yotta-vetter"))
|
|
280
|
+
lines.append("文件数: %d" % scope.get("files", 0))
|
|
281
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
282
|
+
for f in findings:
|
|
283
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
284
|
+
lines.append("")
|
|
285
|
+
lines.append("汇总: CRITICAL %d | HIGH %d | MEDIUM %d | LOW %d | INFO %d" % (
|
|
286
|
+
counts["critical"], counts["high"], counts["medium"],
|
|
287
|
+
counts["low"], counts["info"]))
|
|
288
|
+
verdict, note = verdict_for(_worst(findings))
|
|
289
|
+
lines.append("风险等级: %s" % _worst(findings).upper())
|
|
290
|
+
lines.append("结论: %s" % verdict)
|
|
291
|
+
lines.append("决策记录: %s" % note)
|
|
292
|
+
lines.append("")
|
|
293
|
+
if findings:
|
|
294
|
+
lines.append("发现:")
|
|
295
|
+
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
296
|
+
for f in sorted(findings, key=lambda x: (order.get(x.severity, 9), x.file_path, x.line)):
|
|
297
|
+
loc = f.file_path
|
|
298
|
+
if f.line:
|
|
299
|
+
loc = "%s:%d" % (loc, f.line)
|
|
300
|
+
lines.append(" [%s] %s %s" % (f.severity.upper(), f.rule_id or f.detector, loc))
|
|
301
|
+
lines.append(" %s" % f.description)
|
|
302
|
+
else:
|
|
303
|
+
lines.append("未发现可疑模式。")
|
|
304
|
+
lines.append("=" * 66)
|
|
305
|
+
return "\n".join(lines)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def write_report_md(path, findings, scope):
|
|
309
|
+
lines = []
|
|
310
|
+
lines.append("# SKILL VETTING REPORT")
|
|
311
|
+
lines.append("")
|
|
312
|
+
lines.append("- 技能: %s" % scope.get("skill", "-"))
|
|
313
|
+
lines.append("- 路径: %s" % scope.get("path", "-"))
|
|
314
|
+
lines.append("- 来源: %s" % scope.get("source", "-"))
|
|
315
|
+
lines.append("- 审查时间: %s" % scope.get("reviewed_at", ""))
|
|
316
|
+
lines.append("- 审查者: %s" % scope.get("reviewer", "yotta-vetter"))
|
|
317
|
+
lines.append("- 文件数: %d" % scope.get("files", 0))
|
|
318
|
+
lines.append("")
|
|
319
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
320
|
+
for f in findings:
|
|
321
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
322
|
+
verdict, note = verdict_for(_worst(findings))
|
|
323
|
+
lines.append("## 结论")
|
|
324
|
+
lines.append("")
|
|
325
|
+
lines.append("- 风险等级: %s" % _worst(findings).upper())
|
|
326
|
+
lines.append("- 结论: %s" % verdict)
|
|
327
|
+
lines.append("- 决策记录: %s" % note)
|
|
328
|
+
lines.append("")
|
|
329
|
+
lines.append("## 汇总")
|
|
330
|
+
lines.append("")
|
|
331
|
+
lines.append("| 级别 | 数量 |")
|
|
332
|
+
lines.append("|---|---|")
|
|
333
|
+
for sev in _SEVERITY_ORDER:
|
|
334
|
+
lines.append("| %s | %d |" % (sev.upper(), counts[sev]))
|
|
335
|
+
lines.append("")
|
|
336
|
+
if findings:
|
|
337
|
+
lines.append("## 发现")
|
|
338
|
+
lines.append("")
|
|
339
|
+
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
340
|
+
for f in sorted(findings, key=lambda x: (order.get(x.severity, 9), x.file_path, x.line)):
|
|
341
|
+
lines.append("### %s · %s" % (f.severity.upper(), f.rule_id or f.detector))
|
|
342
|
+
lines.append("")
|
|
343
|
+
lines.append("- 位置: %s%s" % (f.file_path, ":%d" % f.line if f.line else ""))
|
|
344
|
+
lines.append("- 描述: %s" % f.description)
|
|
345
|
+
lines.append("")
|
|
346
|
+
else:
|
|
347
|
+
lines.append("未发现可疑模式。")
|
|
348
|
+
lines.append("## 决策记录")
|
|
349
|
+
lines.append("")
|
|
350
|
+
lines.append("- [ ] 已人工复核发现项")
|
|
351
|
+
lines.append("- [ ] 已确认来源可信度")
|
|
352
|
+
lines.append("- [ ] 已确认权限范围最小化")
|
|
353
|
+
lines.append("- [ ] 审查结论与理由已记录(时间戳/审查者)")
|
|
354
|
+
try:
|
|
355
|
+
with open(str(path), "w", encoding="utf-8", newline="\n") as fh:
|
|
356
|
+
fh.write("\n".join(lines))
|
|
357
|
+
return True
|
|
358
|
+
except OSError as e:
|
|
359
|
+
print("[ERROR] 报告写入失败: %s" % e, file=sys.stderr)
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── check 命令 ─────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def cmd_check(args):
|
|
366
|
+
root = Path(args.path).resolve()
|
|
367
|
+
if not root.is_dir():
|
|
368
|
+
print("[ERROR] 路径不存在或不是目录: %s" % args.path, file=sys.stderr)
|
|
369
|
+
return 4
|
|
370
|
+
files = collect_files(root)
|
|
371
|
+
findings = inventory_checks(root, files)
|
|
372
|
+
findings.extend(pattern_scan(files))
|
|
373
|
+
findings = dedup(findings)
|
|
374
|
+
min_rank = 0
|
|
375
|
+
if args.severity:
|
|
376
|
+
min_rank = _sev_value(args.severity)
|
|
377
|
+
findings = [f for f in findings if _sev_value(f.severity) >= min_rank]
|
|
378
|
+
|
|
379
|
+
scope = {
|
|
380
|
+
"skill": root.name, "path": str(root), "source": args.source or "-",
|
|
381
|
+
"reviewed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
382
|
+
"reviewer": args.reviewer or "yotta-vetter", "files": len(files),
|
|
383
|
+
}
|
|
384
|
+
if args.report:
|
|
385
|
+
write_report_md(args.report, findings, scope)
|
|
386
|
+
if args.json:
|
|
387
|
+
print(json.dumps({
|
|
388
|
+
"tool": TOOL_NAME, "version": VERSION, "scope": scope,
|
|
389
|
+
"summary": _summary(findings),
|
|
390
|
+
"findings": [f.to_dict() for f in findings],
|
|
391
|
+
}, indent=2, ensure_ascii=False))
|
|
392
|
+
else:
|
|
393
|
+
print(fmt_report(findings, scope))
|
|
394
|
+
# V3 联动元安:high 及以上输出深度扫描引导命令(走 stderr,避免污染 --json 输出)
|
|
395
|
+
if _worst(findings) in ("high", "critical"):
|
|
396
|
+
print("", file=sys.stderr)
|
|
397
|
+
print("建议深度扫描(联动元安):", file=sys.stderr)
|
|
398
|
+
print(" yotta-security-audit --target skill --path %s" % root, file=sys.stderr)
|
|
399
|
+
return _sev_value(_worst(findings))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _summary(findings):
|
|
403
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
404
|
+
for f in findings:
|
|
405
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
406
|
+
return counts
|
|
407
|
+
|
|
408
|
+
# ── source 命令(V4 来源半自动化检查)─────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
def cache_dir():
|
|
411
|
+
d = Path.home() / ".cache" / "yotta-vetter"
|
|
412
|
+
try:
|
|
413
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
except OSError:
|
|
415
|
+
d = Path(tempfile.gettempdir()) / "yotta-vetter-cache"
|
|
416
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
return d
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def cache_file(owner, repo):
|
|
421
|
+
return cache_dir() / ("%s__%s.json" % (owner, repo))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def fetch_github_repo(owner, repo, use_cache=True, timeout=15):
|
|
425
|
+
"""拉取 GitHub 仓库元数据;无网络/限速自动降级到本地缓存。"""
|
|
426
|
+
cf = cache_file(owner, repo)
|
|
427
|
+
cached = None
|
|
428
|
+
if use_cache and cf.is_file():
|
|
429
|
+
try:
|
|
430
|
+
cached = json.loads(cf.read_text(encoding="utf-8"))
|
|
431
|
+
except (OSError, json.JSONDecodeError):
|
|
432
|
+
cached = None
|
|
433
|
+
url = "https://api.github.com/repos/%s/%s" % (owner, repo)
|
|
434
|
+
req = urllib.request.Request(url, headers={
|
|
435
|
+
"User-Agent": "yotta-vetter/%s" % VERSION,
|
|
436
|
+
"Accept": "application/vnd.github+json",
|
|
437
|
+
})
|
|
438
|
+
try:
|
|
439
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
440
|
+
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
441
|
+
data["_fetched_at"] = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
442
|
+
try:
|
|
443
|
+
cf.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
return data, None
|
|
447
|
+
except urllib.error.HTTPError as e:
|
|
448
|
+
if e.code == 404:
|
|
449
|
+
return None, "仓库不存在: %s/%s" % (owner, repo)
|
|
450
|
+
if e.code in (401, 403, 429):
|
|
451
|
+
msg = "GitHub API 限速/鉴权失败(HTTP %d),已回退本地缓存" % e.code
|
|
452
|
+
return cached, msg
|
|
453
|
+
return cached, "GitHub API 错误(HTTP %d),已回退本地缓存" % e.code
|
|
454
|
+
except (urllib.error.URLError, OSError) as e:
|
|
455
|
+
return cached, "网络不可用(%s),已回退本地缓存" % (e or "连接失败")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def source_risk_hints(data):
|
|
459
|
+
hints = []
|
|
460
|
+
stars = data.get("stargazers_count", 0)
|
|
461
|
+
if stars < 10:
|
|
462
|
+
hints.append("stars=%d(<10,可信度信号弱)" % stars)
|
|
463
|
+
lic = (data.get("license") or {}).get("spdx_id")
|
|
464
|
+
if not lic:
|
|
465
|
+
hints.append("无许可证声明")
|
|
466
|
+
if data.get("archived"):
|
|
467
|
+
hints.append("仓库已归档(不再维护)")
|
|
468
|
+
updated = data.get("updated_at", "")
|
|
469
|
+
if updated:
|
|
470
|
+
try:
|
|
471
|
+
t = datetime.strptime(updated[:10], "%Y-%m-%d")
|
|
472
|
+
if (datetime.now() - t).days > 365:
|
|
473
|
+
hints.append("超过一年未更新(%s)" % updated[:10])
|
|
474
|
+
except ValueError:
|
|
475
|
+
pass
|
|
476
|
+
if data.get("description"):
|
|
477
|
+
hints.append("描述: %s" % data["description"][:100])
|
|
478
|
+
return hints
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def cmd_source(args):
|
|
482
|
+
spec = args.source
|
|
483
|
+
if spec.startswith("github:"):
|
|
484
|
+
spec = spec[len("github:"):]
|
|
485
|
+
spec = spec.strip().strip("/")
|
|
486
|
+
if "/" not in spec:
|
|
487
|
+
print("[ERROR] 来源格式应为 github:owner/repo 或 owner/repo", file=sys.stderr)
|
|
488
|
+
return 4
|
|
489
|
+
owner, repo = spec.split("/", 1)
|
|
490
|
+
data, warn = fetch_github_repo(owner, repo, use_cache=not args.no_cache)
|
|
491
|
+
if data is None:
|
|
492
|
+
print("[ERROR] %s" % (warn or "获取失败"), file=sys.stderr)
|
|
493
|
+
return 1
|
|
494
|
+
hints = source_risk_hints(data)
|
|
495
|
+
result = {
|
|
496
|
+
"owner": owner, "repo": repo,
|
|
497
|
+
"stars": data.get("stargazers_count", 0),
|
|
498
|
+
"forks": data.get("forks_count", 0),
|
|
499
|
+
"updated_at": data.get("updated_at", ""),
|
|
500
|
+
"license": (data.get("license") or {}).get("spdx_id"),
|
|
501
|
+
"archived": data.get("archived", False),
|
|
502
|
+
"default_branch": data.get("default_branch", ""),
|
|
503
|
+
"description": data.get("description", ""),
|
|
504
|
+
"fetched_at": data.get("_fetched_at", ""),
|
|
505
|
+
"hints": hints,
|
|
506
|
+
"cache": bool((cache_file(owner, repo)).is_file()),
|
|
507
|
+
}
|
|
508
|
+
if args.json:
|
|
509
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
510
|
+
else:
|
|
511
|
+
print("来源检查: %s/%s" % (owner, repo))
|
|
512
|
+
print(" stars=%s forks=%s updated=%s" % (
|
|
513
|
+
result["stars"], result["forks"], (result["updated_at"] or "-")[:10]))
|
|
514
|
+
print(" license=%s archived=%s branch=%s" % (
|
|
515
|
+
result["license"] or "-", result["archived"], result["default_branch"]))
|
|
516
|
+
for h in hints:
|
|
517
|
+
print(" - %s" % h)
|
|
518
|
+
if warn:
|
|
519
|
+
print(" [提示] %s" % warn)
|
|
520
|
+
return 0
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ── 参数解析与入口 ──────────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
class _VetterParser(argparse.ArgumentParser):
|
|
526
|
+
def error(self, message):
|
|
527
|
+
self.print_usage(sys.stderr)
|
|
528
|
+
self.exit(4, "%s: error: %s\n" % (self.prog, message))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def build_parser():
|
|
532
|
+
ap = _VetterParser(prog=TOOL_NAME, description="YottaMeta 元审 —— 技能审查 checker")
|
|
533
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
534
|
+
|
|
535
|
+
pc = sub.add_parser("check", help="四阶段初审")
|
|
536
|
+
pc.add_argument("path", help="技能目录")
|
|
537
|
+
pc.add_argument("--source", default="", help="来源说明(如 github:owner/repo)")
|
|
538
|
+
pc.add_argument("--reviewer", default="", help="审查者")
|
|
539
|
+
pc.add_argument("--json", action="store_true")
|
|
540
|
+
pc.add_argument("--severity", choices=["low", "medium", "high", "critical"])
|
|
541
|
+
pc.add_argument("--report", metavar="FILE")
|
|
542
|
+
pc.add_argument("--no-color", action="store_true")
|
|
543
|
+
|
|
544
|
+
ps = sub.add_parser("source", help="来源半自动化检查")
|
|
545
|
+
ps.add_argument("source", help="github:owner/repo 或 owner/repo")
|
|
546
|
+
ps.add_argument("--json", action="store_true")
|
|
547
|
+
ps.add_argument("--no-cache", action="store_true")
|
|
548
|
+
ps.add_argument("--report", metavar="FILE")
|
|
549
|
+
|
|
550
|
+
return ap
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def main(argv=None):
|
|
554
|
+
ap = build_parser()
|
|
555
|
+
args = ap.parse_args(argv)
|
|
556
|
+
try:
|
|
557
|
+
if args.command == "check":
|
|
558
|
+
return cmd_check(args)
|
|
559
|
+
if args.command == "source":
|
|
560
|
+
return cmd_source(args)
|
|
561
|
+
ap.error("未知命令: %s" % args.command)
|
|
562
|
+
except OSError as e:
|
|
563
|
+
print("[ERROR] 文件操作失败: %s" % e, file=sys.stderr)
|
|
564
|
+
return 4
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
try:
|
|
569
|
+
sys.exit(main())
|
|
570
|
+
except SystemExit:
|
|
571
|
+
raise
|
|
572
|
+
except KeyboardInterrupt:
|
|
573
|
+
sys.exit(4)
|
|
574
|
+
except Exception as e:
|
|
575
|
+
print("[FATAL] %s: %s" % (TOOL_NAME, e), file=sys.stderr)
|
|
576
|
+
sys.exit(4)
|