@pwddd/skills-scanner 1.2.4 → 2.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.
Potentially problematic release.
This version of @pwddd/skills-scanner might be problematic. Click here for more details.
- package/README.md +130 -7
- package/index.ts +170 -95
- package/openclaw.plugin.json +22 -3
- package/package.json +3 -3
- package/skills/skills-scanner/scan.py +461 -240
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# /// script
|
|
2
2
|
# dependencies = [
|
|
3
|
-
# "
|
|
3
|
+
# "requests>=2.31.0",
|
|
4
4
|
# ]
|
|
5
5
|
# ///
|
|
6
6
|
"""
|
|
7
|
-
OpenClaw Skills Security Scanner
|
|
8
|
-
|
|
7
|
+
OpenClaw Skills Security Scanner (HTTP Client)
|
|
8
|
+
通过 HTTP API 调用远程 cisco-ai-skill-scanner 服务
|
|
9
9
|
|
|
10
10
|
注意:此脚本必须使用 venv 中的 Python 运行
|
|
11
11
|
"""
|
|
@@ -14,36 +14,27 @@ import sys
|
|
|
14
14
|
import os
|
|
15
15
|
import json
|
|
16
16
|
import argparse
|
|
17
|
+
import tempfile
|
|
18
|
+
import zipfile
|
|
19
|
+
import time
|
|
17
20
|
from pathlib import Path
|
|
21
|
+
from typing import Optional, Dict, Any, List
|
|
18
22
|
|
|
19
23
|
# ── 依赖检查 ──────────────────────────────────────────────────────────────────
|
|
20
24
|
try:
|
|
21
|
-
|
|
22
|
-
from skill_scanner.core.analyzers import (
|
|
23
|
-
StaticAnalyzer,
|
|
24
|
-
BehavioralAnalyzer,
|
|
25
|
-
PipelineAnalyzer,
|
|
26
|
-
)
|
|
25
|
+
import requests
|
|
27
26
|
except ImportError as e:
|
|
28
|
-
print("❌
|
|
27
|
+
print("❌ requests 未安装。")
|
|
29
28
|
print(f" 导入错误: {e}")
|
|
30
|
-
print(" 请运行: uv pip install
|
|
31
|
-
print(f" Python 路径: {sys.executable}")
|
|
32
|
-
print(f" Python 版本: {sys.version}")
|
|
33
|
-
|
|
34
|
-
# 尝试显示 sys.path
|
|
35
|
-
print(" sys.path:")
|
|
36
|
-
for p in sys.path:
|
|
37
|
-
print(f" - {p}")
|
|
38
|
-
|
|
39
|
-
sys.exit(1)
|
|
40
|
-
except Exception as e:
|
|
41
|
-
print(f"❌ 导入时发生未知错误: {type(e).__name__}: {e}")
|
|
42
|
-
import traceback
|
|
43
|
-
traceback.print_exc()
|
|
29
|
+
print(" 请运行: uv pip install requests")
|
|
44
30
|
sys.exit(1)
|
|
45
31
|
|
|
46
32
|
|
|
33
|
+
# ── 配置 ──────────────────────────────────────────────────────────────────────
|
|
34
|
+
DEFAULT_API_URL = os.getenv("SKILL_SCANNER_API_URL", "http://localhost:8000")
|
|
35
|
+
REQUEST_TIMEOUT = 180 # 3 分钟超时
|
|
36
|
+
|
|
37
|
+
|
|
47
38
|
# ── 颜色输出 ──────────────────────────────────────────────────────────────────
|
|
48
39
|
USE_COLOR = sys.stdout.isatty()
|
|
49
40
|
|
|
@@ -58,231 +49,461 @@ BOLD = lambda t: c(t, "1")
|
|
|
58
49
|
DIM = lambda t: c(t, "2")
|
|
59
50
|
|
|
60
51
|
|
|
61
|
-
# ──
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
sev = (sev or "INFO").upper()
|
|
72
|
-
color = SEVERITY_COLORS.get(sev, CYAN)
|
|
73
|
-
return color(f"[{sev}]")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# ── 构建 Scanner ──────────────────────────────────────────────────────────────
|
|
77
|
-
def build_scanner(use_behavioral: bool = False, use_llm: bool = False) -> SkillScanner:
|
|
78
|
-
analyzers = [StaticAnalyzer(), PipelineAnalyzer()]
|
|
79
|
-
if use_behavioral:
|
|
80
|
-
analyzers.append(BehavioralAnalyzer())
|
|
81
|
-
if use_llm:
|
|
52
|
+
# ── HTTP 客户端 ───────────────────────────────────────────────────────────────
|
|
53
|
+
class SkillScannerClient:
|
|
54
|
+
"""cisco-ai-skill-scanner HTTP API 客户端"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, base_url: str = DEFAULT_API_URL):
|
|
57
|
+
self.base_url = base_url.rstrip('/')
|
|
58
|
+
self.session = requests.Session()
|
|
59
|
+
|
|
60
|
+
def health_check(self) -> Dict[str, Any]:
|
|
61
|
+
"""健康检查,返回详细信息"""
|
|
82
62
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
except
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
63
|
+
response = self.session.get(f"{self.base_url}/health", timeout=5)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return {
|
|
66
|
+
'status': 'healthy',
|
|
67
|
+
'data': response.json()
|
|
68
|
+
}
|
|
69
|
+
except requests.exceptions.ConnectionError:
|
|
70
|
+
return {
|
|
71
|
+
'status': 'unreachable',
|
|
72
|
+
'error': f'无法连接到 {self.base_url}'
|
|
73
|
+
}
|
|
74
|
+
except requests.exceptions.Timeout:
|
|
75
|
+
return {
|
|
76
|
+
'status': 'timeout',
|
|
77
|
+
'error': '请求超时'
|
|
78
|
+
}
|
|
79
|
+
except Exception as e:
|
|
80
|
+
return {
|
|
81
|
+
'status': 'error',
|
|
82
|
+
'error': str(e)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def scan_upload(
|
|
86
|
+
self,
|
|
87
|
+
skill_path: str,
|
|
88
|
+
policy: str = "balanced",
|
|
89
|
+
use_llm: bool = False,
|
|
90
|
+
use_behavioral: bool = False,
|
|
91
|
+
use_virustotal: bool = False,
|
|
92
|
+
use_aidefense: bool = False,
|
|
93
|
+
use_trigger: bool = False,
|
|
94
|
+
enable_meta: bool = False,
|
|
95
|
+
vt_api_key: Optional[str] = None,
|
|
96
|
+
aidefense_api_key: Optional[str] = None
|
|
97
|
+
) -> Dict[str, Any]:
|
|
98
|
+
"""上传 ZIP 文件扫描(单个 Skill)
|
|
99
|
+
|
|
100
|
+
对应 API: POST /scan-upload
|
|
101
|
+
- 上传 ZIP 文件
|
|
102
|
+
- 服务器解压并查找 SKILL.md
|
|
103
|
+
- 返回扫描结果
|
|
104
|
+
"""
|
|
105
|
+
# 创建临时 ZIP 文件
|
|
106
|
+
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_zip:
|
|
107
|
+
zip_path = tmp_zip.name
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
# 打包 Skill 目录
|
|
111
|
+
self._create_zip(skill_path, zip_path)
|
|
112
|
+
|
|
113
|
+
# 准备请求
|
|
114
|
+
with open(zip_path, 'rb') as f:
|
|
115
|
+
files = {'file': (os.path.basename(skill_path) + '.zip', f, 'application/zip')}
|
|
116
|
+
data = {
|
|
117
|
+
'policy': policy,
|
|
118
|
+
'use_llm': str(use_llm).lower(),
|
|
119
|
+
'use_behavioral': str(use_behavioral).lower(),
|
|
120
|
+
'use_virustotal': str(use_virustotal).lower(),
|
|
121
|
+
'use_aidefense': str(use_aidefense).lower(),
|
|
122
|
+
'use_trigger': str(use_trigger).lower(),
|
|
123
|
+
'enable_meta': str(enable_meta).lower()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# 添加认证头
|
|
127
|
+
headers = {}
|
|
128
|
+
if vt_api_key:
|
|
129
|
+
headers['X-VirusTotal-Key'] = vt_api_key
|
|
130
|
+
if aidefense_api_key:
|
|
131
|
+
headers['X-AIDefense-Key'] = aidefense_api_key
|
|
132
|
+
|
|
133
|
+
response = self.session.post(
|
|
134
|
+
f"{self.base_url}/scan-upload",
|
|
135
|
+
files=files,
|
|
136
|
+
data=data,
|
|
137
|
+
headers=headers,
|
|
138
|
+
timeout=REQUEST_TIMEOUT
|
|
139
|
+
)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
return response.json()
|
|
142
|
+
finally:
|
|
143
|
+
# 清理临时文件
|
|
144
|
+
if os.path.exists(zip_path):
|
|
145
|
+
os.unlink(zip_path)
|
|
146
|
+
|
|
147
|
+
def scan_batch_upload(
|
|
148
|
+
self,
|
|
149
|
+
skill_paths: List[str],
|
|
150
|
+
policy: str = "balanced",
|
|
151
|
+
use_llm: bool = False,
|
|
152
|
+
use_behavioral: bool = False,
|
|
153
|
+
max_concurrent: int = 3
|
|
154
|
+
) -> List[Dict[str, Any]]:
|
|
155
|
+
"""批量上传多个 Skill(客户端循环上传)
|
|
156
|
+
|
|
157
|
+
注意:API 不支持批量上传,这里是客户端实现
|
|
158
|
+
"""
|
|
159
|
+
results = []
|
|
160
|
+
|
|
161
|
+
for i, skill_path in enumerate(skill_paths, 1):
|
|
162
|
+
print(f"[{i}/{len(skill_paths)}] 正在扫描: {skill_path}")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
result = self.scan_upload(
|
|
166
|
+
skill_path,
|
|
167
|
+
policy=policy,
|
|
168
|
+
use_llm=use_llm,
|
|
169
|
+
use_behavioral=use_behavioral
|
|
170
|
+
)
|
|
171
|
+
results.append({
|
|
172
|
+
'path': skill_path,
|
|
173
|
+
'success': True,
|
|
174
|
+
'result': result
|
|
175
|
+
})
|
|
176
|
+
status = "✓" if result.get('is_safe', False) else "✗"
|
|
177
|
+
print(f" {status} {result.get('skill_name', 'Unknown')}: {result.get('findings_count', 0)} 个发现")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
results.append({
|
|
180
|
+
'path': skill_path,
|
|
181
|
+
'success': False,
|
|
182
|
+
'error': str(e)
|
|
183
|
+
})
|
|
184
|
+
print(f" ✗ 失败: {e}")
|
|
185
|
+
|
|
186
|
+
return results
|
|
187
|
+
|
|
188
|
+
def scan_batch(
|
|
189
|
+
self,
|
|
190
|
+
skills_dir: str,
|
|
191
|
+
recursive: bool = False,
|
|
192
|
+
check_overlap: bool = False,
|
|
193
|
+
policy: str = "balanced",
|
|
194
|
+
use_llm: bool = False,
|
|
195
|
+
use_behavioral: bool = False
|
|
196
|
+
) -> str:
|
|
197
|
+
"""批量异步扫描(服务器本地目录),返回 scan_id
|
|
198
|
+
|
|
199
|
+
对应 API: POST /scan-batch
|
|
200
|
+
- 扫描服务器本地目录中的多个技能
|
|
201
|
+
- 异步执行,返回 scan_id
|
|
202
|
+
- 需要轮询 GET /scan-batch/{scan_id} 获取结果
|
|
203
|
+
"""
|
|
204
|
+
data = {
|
|
205
|
+
'skills_directory': skills_dir,
|
|
206
|
+
'recursive': recursive,
|
|
207
|
+
'check_overlap': check_overlap,
|
|
208
|
+
'policy': policy,
|
|
209
|
+
'use_llm': use_llm,
|
|
210
|
+
'use_behavioral': use_behavioral
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
response = self.session.post(
|
|
214
|
+
f"{self.base_url}/scan-batch",
|
|
215
|
+
json=data,
|
|
216
|
+
timeout=30
|
|
217
|
+
)
|
|
218
|
+
response.raise_for_status()
|
|
219
|
+
return response.json()['scan_id']
|
|
220
|
+
|
|
221
|
+
def get_batch_result(self, scan_id: str) -> Dict[str, Any]:
|
|
222
|
+
"""获取批量扫描结果"""
|
|
223
|
+
response = self.session.get(
|
|
224
|
+
f"{self.base_url}/scan-batch/{scan_id}",
|
|
225
|
+
timeout=10
|
|
226
|
+
)
|
|
227
|
+
response.raise_for_status()
|
|
228
|
+
return response.json()
|
|
229
|
+
|
|
230
|
+
def wait_for_batch(self, scan_id: str, poll_interval: int = 5) -> Dict[str, Any]:
|
|
231
|
+
"""等待批量扫描完成"""
|
|
232
|
+
while True:
|
|
233
|
+
result = self.get_batch_result(scan_id)
|
|
234
|
+
status = result.get('status')
|
|
235
|
+
|
|
236
|
+
if status == 'completed':
|
|
237
|
+
return result['result']
|
|
238
|
+
elif status == 'failed':
|
|
239
|
+
raise Exception(f"批量扫描失败: {result.get('error')}")
|
|
240
|
+
|
|
241
|
+
time.sleep(poll_interval)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _create_zip(source_dir: str, zip_path: str):
|
|
245
|
+
"""创建 ZIP 文件"""
|
|
246
|
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
247
|
+
source_path = Path(source_dir)
|
|
248
|
+
for file_path in source_path.rglob('*'):
|
|
249
|
+
if file_path.is_file():
|
|
250
|
+
arcname = file_path.relative_to(source_path.parent)
|
|
251
|
+
zipf.write(file_path, arcname)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── 格式化输出 ────────────────────────────────────────────────────────────────
|
|
255
|
+
def format_scan_result(result: Dict[str, Any], detailed: bool = False) -> str:
|
|
256
|
+
"""格式化扫描结果"""
|
|
257
|
+
lines = []
|
|
258
|
+
|
|
259
|
+
# 基本信息
|
|
260
|
+
skill_name = result.get('skill_name', 'Unknown')
|
|
261
|
+
is_safe = result.get('is_safe', False)
|
|
262
|
+
max_severity = result.get('max_severity', 'NONE')
|
|
263
|
+
findings_count = result.get('findings_count', 0)
|
|
264
|
+
|
|
120
265
|
# 状态行
|
|
121
|
-
if is_safe
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for i,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
# 非 detailed 模式只显示 HIGH/CRITICAL
|
|
143
|
-
serious = [f for f in findings if str(getattr(f, "severity", "")).upper() in ("CRITICAL", "HIGH")]
|
|
144
|
-
if serious:
|
|
145
|
-
print(f"\n {BOLD('HIGH/CRITICAL 问题:')}")
|
|
146
|
-
for f in serious:
|
|
147
|
-
sev = str(getattr(f, "severity", "HIGH")).upper()
|
|
148
|
-
name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
|
|
149
|
-
desc = getattr(f, "description", getattr(f, "message", ""))
|
|
150
|
-
print(f" {severity_label(sev)} {BOLD(name)}: {desc}")
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
"path": str(skill_path),
|
|
154
|
-
"name": skill_path.name,
|
|
155
|
-
"is_safe": is_safe,
|
|
156
|
-
"max_severity": max_sev,
|
|
157
|
-
"findings": len(findings),
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# ── 批量扫描 ──────────────────────────────────────────────────────────────────
|
|
162
|
-
def scan_batch(directory: str, args) -> list[dict]:
|
|
163
|
-
base = Path(directory).expanduser().resolve()
|
|
164
|
-
|
|
165
|
-
if not base.exists() or not base.is_dir():
|
|
166
|
-
print(RED(f"❌ 目录不存在: {base}"))
|
|
167
|
-
return []
|
|
266
|
+
status_icon = GREEN("✓") if is_safe else RED("✗")
|
|
267
|
+
lines.append(f"{status_icon} {BOLD(skill_name)}")
|
|
268
|
+
lines.append(f" 严重性: {_severity_color(max_severity)}")
|
|
269
|
+
lines.append(f" 发现数: {findings_count}")
|
|
270
|
+
|
|
271
|
+
# 详细发现
|
|
272
|
+
if detailed and findings_count > 0:
|
|
273
|
+
findings = result.get('findings', [])
|
|
274
|
+
lines.append("")
|
|
275
|
+
lines.append(BOLD("发现详情:"))
|
|
276
|
+
for i, finding in enumerate(findings[:10], 1): # 最多显示 10 条
|
|
277
|
+
severity = finding.get('severity', 'UNKNOWN')
|
|
278
|
+
category = finding.get('category', 'Unknown')
|
|
279
|
+
description = finding.get('description', 'No description')
|
|
280
|
+
lines.append(f" {i}. [{_severity_color(severity)}] {category}")
|
|
281
|
+
lines.append(f" {description}")
|
|
282
|
+
|
|
283
|
+
if len(findings) > 10:
|
|
284
|
+
lines.append(f" ... 还有 {len(findings) - 10} 条发现")
|
|
285
|
+
|
|
286
|
+
return "\n".join(lines)
|
|
168
287
|
|
|
169
|
-
# 查找所有含 SKILL.md 的子目录
|
|
170
|
-
skills: list[Path] = []
|
|
171
288
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
289
|
+
def format_batch_result(result: Dict[str, Any]) -> str:
|
|
290
|
+
"""格式化批量扫描结果"""
|
|
291
|
+
lines = []
|
|
292
|
+
|
|
293
|
+
total = result.get('total_skills_scanned', 0)
|
|
294
|
+
safe = result.get('safe_skills', 0)
|
|
295
|
+
unsafe = result.get('unsafe_skills', 0)
|
|
296
|
+
|
|
297
|
+
lines.append(BOLD("批量扫描结果"))
|
|
298
|
+
lines.append(f" 总计: {total} 个 Skills")
|
|
299
|
+
lines.append(f" 安全: {GREEN(str(safe))}")
|
|
300
|
+
lines.append(f" 问题: {RED(str(unsafe))}")
|
|
301
|
+
|
|
302
|
+
# 问题 Skills 列表
|
|
303
|
+
if unsafe > 0:
|
|
304
|
+
skills = result.get('skills', [])
|
|
305
|
+
unsafe_skills = [s for s in skills if not s.get('is_safe', True)]
|
|
306
|
+
lines.append("")
|
|
307
|
+
lines.append(BOLD("问题 Skills:"))
|
|
308
|
+
for skill in unsafe_skills[:10]:
|
|
309
|
+
name = skill.get('skill_name', 'Unknown')
|
|
310
|
+
severity = skill.get('max_severity', 'UNKNOWN')
|
|
311
|
+
count = skill.get('findings_count', 0)
|
|
312
|
+
lines.append(f" • {name} [{_severity_color(severity)}] - {count} 条发现")
|
|
313
|
+
|
|
314
|
+
return "\n".join(lines)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _severity_color(severity: str) -> str:
|
|
318
|
+
"""严重性着色"""
|
|
319
|
+
severity_upper = severity.upper()
|
|
320
|
+
if severity_upper in ('CRITICAL', 'HIGH'):
|
|
321
|
+
return RED(severity_upper)
|
|
322
|
+
elif severity_upper == 'MEDIUM':
|
|
323
|
+
return YELLOW(severity_upper)
|
|
324
|
+
elif severity_upper == 'LOW':
|
|
325
|
+
return CYAN(severity_upper)
|
|
175
326
|
else:
|
|
176
|
-
|
|
177
|
-
if entry.is_dir() and (entry / "SKILL.md").exists():
|
|
178
|
-
skills.append(entry)
|
|
179
|
-
|
|
180
|
-
if not skills:
|
|
181
|
-
print(YELLOW(f"⚠️ 在 {base} 中未找到任何 Skill(含 SKILL.md 的目录)"))
|
|
182
|
-
return []
|
|
183
|
-
|
|
184
|
-
print(f"\n{BOLD('📂 批量扫描目录:')} {CYAN(str(base))}")
|
|
185
|
-
print(f" 发现 {BOLD(str(len(skills)))} 个 Skill\n")
|
|
186
|
-
print("─" * 60)
|
|
187
|
-
|
|
188
|
-
results = []
|
|
189
|
-
safe_count = 0
|
|
190
|
-
unsafe_count = 0
|
|
191
|
-
error_count = 0
|
|
192
|
-
|
|
193
|
-
for skill_path in skills:
|
|
194
|
-
r = scan_single(str(skill_path), args)
|
|
195
|
-
results.append(r)
|
|
196
|
-
if r.get("error"):
|
|
197
|
-
error_count += 1
|
|
198
|
-
elif r.get("is_safe"):
|
|
199
|
-
safe_count += 1
|
|
200
|
-
else:
|
|
201
|
-
unsafe_count += 1
|
|
202
|
-
|
|
203
|
-
# 汇总
|
|
204
|
-
print("\n" + "═" * 60)
|
|
205
|
-
print(BOLD("📊 批量扫描汇总"))
|
|
206
|
-
print("═" * 60)
|
|
207
|
-
print(f" 总计: {len(results)} 个 Skill")
|
|
208
|
-
print(f" {GREEN('✅ 安全:')} {safe_count} 个")
|
|
209
|
-
print(f" {RED('❌ 问题:')} {unsafe_count} 个")
|
|
210
|
-
if error_count:
|
|
211
|
-
print(f" {YELLOW('⚠️ 错误:')} {error_count} 个")
|
|
327
|
+
return DIM(severity_upper)
|
|
212
328
|
|
|
213
|
-
# 问题 Skill 列表
|
|
214
|
-
unsafe = [r for r in results if not r.get("is_safe") and not r.get("error")]
|
|
215
|
-
if unsafe:
|
|
216
|
-
print(f"\n {BOLD(RED('需要关注的 Skills:'))}")
|
|
217
|
-
for r in unsafe:
|
|
218
|
-
sev = r.get("max_severity", "UNKNOWN")
|
|
219
|
-
print(f" {severity_label(sev)} {r['name']} ({r.get('findings', 0)} 条发现)")
|
|
220
|
-
print(f" {DIM(r['path'])}")
|
|
221
329
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
# ── JSON 输出 ─────────────────────────────────────────────────────────────────
|
|
226
|
-
def save_json(data, output_path: str):
|
|
227
|
-
with open(output_path, "w", encoding="utf-8") as f:
|
|
228
|
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
229
|
-
print(f"\n{GREEN('💾 结果已保存:')} {output_path}")
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
# ── CLI 入口 ──────────────────────────────────────────────────────────────────
|
|
330
|
+
# ── 命令行接口 ────────────────────────────────────────────────────────────────
|
|
233
331
|
def main():
|
|
234
332
|
parser = argparse.ArgumentParser(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
238
|
-
epilog="""
|
|
239
|
-
示例:
|
|
240
|
-
单个扫描:
|
|
241
|
-
python scan.py scan ~/.openclaw/skills/my-skill
|
|
242
|
-
python scan.py scan ./my-skill --detailed --behavioral
|
|
243
|
-
|
|
244
|
-
批量扫描:
|
|
245
|
-
python scan.py batch ~/.openclaw/skills
|
|
246
|
-
python scan.py batch ~/.openclaw/skills --recursive --json results.json
|
|
247
|
-
python scan.py batch ./skills --detailed --llm
|
|
248
|
-
""",
|
|
333
|
+
description="OpenClaw Skills Security Scanner (HTTP Client)",
|
|
334
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
249
335
|
)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
336
|
+
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
'--api-url',
|
|
339
|
+
default=DEFAULT_API_URL,
|
|
340
|
+
help=f'API 服务地址 (默认: {DEFAULT_API_URL})'
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
subparsers = parser.add_subparsers(dest='command', help='命令')
|
|
344
|
+
|
|
345
|
+
# scan 命令
|
|
346
|
+
scan_parser = subparsers.add_parser('scan', help='扫描单个 Skill(上传 ZIP)')
|
|
347
|
+
scan_parser.add_argument('path', help='Skill 目录路径')
|
|
348
|
+
scan_parser.add_argument('--detailed', action='store_true', help='显示详细发现')
|
|
349
|
+
scan_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
350
|
+
scan_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
351
|
+
scan_parser.add_argument('--virustotal', action='store_true', help='启用 VirusTotal')
|
|
352
|
+
scan_parser.add_argument('--aidefense', action='store_true', help='启用 AI Defense')
|
|
353
|
+
scan_parser.add_argument('--trigger', action='store_true', help='启用触发器分析')
|
|
354
|
+
scan_parser.add_argument('--meta', action='store_true', help='启用元分析(误报过滤)')
|
|
355
|
+
scan_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
356
|
+
scan_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
357
|
+
|
|
358
|
+
# batch 命令(服务器本地路径批量扫描)
|
|
359
|
+
batch_parser = subparsers.add_parser('batch', help='批量扫描(服务器本地目录)')
|
|
360
|
+
batch_parser.add_argument('path', help='Skills 目录路径(服务器本地)')
|
|
361
|
+
batch_parser.add_argument('--recursive', action='store_true', help='递归扫描')
|
|
362
|
+
batch_parser.add_argument('--check-overlap', action='store_true', help='检查技能描述重叠')
|
|
363
|
+
batch_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
364
|
+
batch_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
365
|
+
|
|
366
|
+
# batch-upload 命令(客户端批量上传)
|
|
367
|
+
batch_upload_parser = subparsers.add_parser('batch-upload', help='批量上传多个 Skills(客户端循环)')
|
|
368
|
+
batch_upload_parser.add_argument('paths', nargs='+', help='多个 Skill 目录路径')
|
|
369
|
+
batch_upload_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
370
|
+
batch_upload_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
371
|
+
batch_upload_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
372
|
+
batch_upload_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
373
|
+
|
|
374
|
+
# health 命令
|
|
375
|
+
subparsers.add_parser('health', help='健康检查')
|
|
376
|
+
|
|
270
377
|
args = parser.parse_args()
|
|
271
|
-
|
|
272
|
-
if args.command
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
378
|
+
|
|
379
|
+
if not args.command:
|
|
380
|
+
parser.print_help()
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
|
|
383
|
+
# 创建客户端
|
|
384
|
+
client = SkillScannerClient(args.api_url)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
if args.command == 'health':
|
|
388
|
+
health_result = client.health_check()
|
|
389
|
+
|
|
390
|
+
if health_result['status'] == 'healthy':
|
|
391
|
+
print(GREEN("✓") + " API 服务正常")
|
|
392
|
+
|
|
393
|
+
# 显示详细信息
|
|
394
|
+
data = health_result.get('data', {})
|
|
395
|
+
if data:
|
|
396
|
+
print(f" 版本: {data.get('version', 'Unknown')}")
|
|
397
|
+
analyzers = data.get('analyzers_available', [])
|
|
398
|
+
if analyzers:
|
|
399
|
+
print(f" 可用分析器: {', '.join(analyzers)}")
|
|
400
|
+
|
|
401
|
+
# 输出 JSON 供程序解析
|
|
402
|
+
print(json.dumps(data))
|
|
403
|
+
|
|
404
|
+
sys.exit(0)
|
|
405
|
+
else:
|
|
406
|
+
print(RED("✗") + f" API 服务不可用: {args.api_url}")
|
|
407
|
+
error = health_result.get('error', '未知错误')
|
|
408
|
+
print(f" 错误: {error}")
|
|
409
|
+
sys.exit(1)
|
|
410
|
+
|
|
411
|
+
elif args.command == 'scan':
|
|
412
|
+
print(f"正在扫描: {args.path}")
|
|
413
|
+
result = client.scan_upload(
|
|
414
|
+
args.path,
|
|
415
|
+
policy=args.policy,
|
|
416
|
+
use_llm=args.llm,
|
|
417
|
+
use_behavioral=args.behavioral,
|
|
418
|
+
use_virustotal=args.virustotal if hasattr(args, 'virustotal') else False,
|
|
419
|
+
use_aidefense=args.aidefense if hasattr(args, 'aidefense') else False,
|
|
420
|
+
use_trigger=args.trigger if hasattr(args, 'trigger') else False,
|
|
421
|
+
enable_meta=args.meta if hasattr(args, 'meta') else False
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# 输出结果
|
|
425
|
+
if args.json:
|
|
426
|
+
with open(args.json, 'w') as f:
|
|
427
|
+
json.dump(result, f, indent=2)
|
|
428
|
+
print(f"结果已保存到: {args.json}")
|
|
429
|
+
else:
|
|
430
|
+
print(format_scan_result(result, args.detailed))
|
|
431
|
+
|
|
432
|
+
# 退出码
|
|
433
|
+
sys.exit(0 if result.get('is_safe', False) else 1)
|
|
434
|
+
|
|
435
|
+
elif args.command == 'batch':
|
|
436
|
+
print(f"正在批量扫描: {args.path}")
|
|
437
|
+
scan_id = client.scan_batch(
|
|
438
|
+
args.path,
|
|
439
|
+
recursive=args.recursive,
|
|
440
|
+
check_overlap=args.check_overlap if hasattr(args, 'check_overlap') else False,
|
|
441
|
+
policy=args.policy
|
|
442
|
+
)
|
|
443
|
+
print(f"扫描 ID: {scan_id}")
|
|
444
|
+
print("等待扫描完成...")
|
|
445
|
+
|
|
446
|
+
result = client.wait_for_batch(scan_id)
|
|
447
|
+
|
|
448
|
+
# 输出结果
|
|
449
|
+
if args.json:
|
|
450
|
+
with open(args.json, 'w') as f:
|
|
451
|
+
json.dump(result, f, indent=2)
|
|
452
|
+
print(f"结果已保存到: {args.json}")
|
|
453
|
+
else:
|
|
454
|
+
print(format_batch_result(result))
|
|
455
|
+
|
|
456
|
+
# 退出码
|
|
457
|
+
unsafe = result.get('unsafe_skills', 0)
|
|
458
|
+
sys.exit(0 if unsafe == 0 else 1)
|
|
459
|
+
|
|
460
|
+
elif args.command == 'batch-upload':
|
|
461
|
+
print(f"正在批量上传 {len(args.paths)} 个 Skills...")
|
|
462
|
+
results = client.scan_batch_upload(
|
|
463
|
+
args.paths,
|
|
464
|
+
policy=args.policy,
|
|
465
|
+
use_llm=args.llm,
|
|
466
|
+
use_behavioral=args.behavioral
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# 统计结果
|
|
470
|
+
total = len(results)
|
|
471
|
+
success = sum(1 for r in results if r['success'])
|
|
472
|
+
failed = total - success
|
|
473
|
+
|
|
474
|
+
# 输出结果
|
|
475
|
+
if args.json:
|
|
476
|
+
with open(args.json, 'w') as f:
|
|
477
|
+
json.dump(results, f, indent=2)
|
|
478
|
+
print(f"\n结果已保存到: {args.json}")
|
|
479
|
+
|
|
480
|
+
print(f"\n批量上传完成: {success}/{total} 成功, {failed} 失败")
|
|
481
|
+
|
|
482
|
+
# 退出码
|
|
483
|
+
sys.exit(0 if failed == 0 else 1)
|
|
484
|
+
|
|
485
|
+
except requests.exceptions.ConnectionError:
|
|
486
|
+
print(RED("✗") + f" 无法连接到 API 服务: {args.api_url}")
|
|
487
|
+
print("请确保 skill-scanner-api 服务正在运行")
|
|
488
|
+
sys.exit(1)
|
|
489
|
+
except requests.exceptions.Timeout:
|
|
490
|
+
print(RED("✗") + " 请求超时")
|
|
491
|
+
sys.exit(1)
|
|
492
|
+
except requests.exceptions.HTTPError as e:
|
|
493
|
+
print(RED("✗") + f" HTTP 错误: {e}")
|
|
494
|
+
if e.response is not None:
|
|
495
|
+
try:
|
|
496
|
+
error_detail = e.response.json()
|
|
497
|
+
print(f"详情: {error_detail}")
|
|
498
|
+
except:
|
|
499
|
+
print(f"响应: {e.response.text}")
|
|
500
|
+
sys.exit(1)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
print(RED("✗") + f" 错误: {e}")
|
|
503
|
+
import traceback
|
|
504
|
+
traceback.print_exc()
|
|
505
|
+
sys.exit(1)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
if __name__ == '__main__':
|
|
288
509
|
main()
|