@pwddd/skills-scanner 1.2.0 → 2.0.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/README.md +130 -7
- package/index.ts +131 -71
- package/openclaw.plugin.json +22 -3
- package/package.json +1 -1
- package/skills/skills-scanner/scan.py +432 -230
package/README.md
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
# openclaw-skills-scanner
|
|
2
2
|
|
|
3
|
-
OpenClaw Plugin
|
|
3
|
+
OpenClaw Plugin,通过 HTTP API 调用远程 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) 服务实现对 OpenClaw Skills 的安全扫描。
|
|
4
|
+
|
|
5
|
+
## 架构说明
|
|
6
|
+
|
|
7
|
+
本插件采用 **HTTP 客户端模式**:
|
|
8
|
+
- **客户端**:本插件(TypeScript + Python HTTP 客户端)
|
|
9
|
+
- **服务端**:独立运行的 `skill-scanner-api` 服务(需单独部署)
|
|
10
|
+
- **通信方式**:通过 HTTP REST API 调用扫描服务
|
|
11
|
+
|
|
12
|
+
### 为什么使用 HTTP 模式?
|
|
13
|
+
|
|
14
|
+
1. **依赖隔离**:避免复杂的 Python 依赖安装问题(LiteLLM、代理配置等)
|
|
15
|
+
2. **服务复用**:多个客户端可共享同一个扫描服务
|
|
16
|
+
3. **灵活部署**:扫描服务可部署在专用服务器上,支持更强大的硬件配置
|
|
17
|
+
4. **简化维护**:客户端只需 `requests` 库,无需安装完整的 `cisco-ai-skill-scanner`
|
|
4
18
|
|
|
5
19
|
## 功能
|
|
6
20
|
|
|
7
21
|
| 功能 | 实现方式 | 状态 |
|
|
8
22
|
|---|---|---|
|
|
9
|
-
| 启动时自动安装 Python
|
|
23
|
+
| 启动时自动安装 Python 依赖(requests) | `registerService` | ✅ 全自动 |
|
|
10
24
|
| `/scan-skill` `/scan-skills` 按需扫描 | `registerCommand` | ✅ 全自动 |
|
|
11
25
|
| `/scan-report` 立即日报 | `registerCommand` | ✅ 全自动 |
|
|
12
26
|
| `/scan-status` 状态查看 | `registerCommand` | ✅ 全自动 |
|
|
@@ -17,21 +31,47 @@ OpenClaw Plugin,基于 [cisco-ai-skill-scanner](https://github.com/cisco-ai-de
|
|
|
17
31
|
|
|
18
32
|
---
|
|
19
33
|
|
|
20
|
-
##
|
|
34
|
+
## 前置要求
|
|
35
|
+
|
|
36
|
+
### 1. 启动 skill-scanner-api 服务
|
|
37
|
+
|
|
38
|
+
在服务器上安装并启动 API 服务:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 安装 cisco-ai-skill-scanner
|
|
42
|
+
pip install cisco-ai-skill-scanner
|
|
43
|
+
|
|
44
|
+
# 启动 API 服务(默认端口 8000)
|
|
45
|
+
skill-scanner-api
|
|
46
|
+
|
|
47
|
+
# 或指定端口
|
|
48
|
+
skill-scanner-api --port 8080
|
|
49
|
+
|
|
50
|
+
# 开发模式(自动重载)
|
|
51
|
+
skill-scanner-api --reload
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
验证服务是否正常:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
curl http://localhost:8000/health
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. 安装本插件
|
|
21
61
|
|
|
22
62
|
```bash
|
|
23
63
|
# 从本地目录安装
|
|
24
64
|
openclaw plugins install ./openclaw-skills-scanner
|
|
25
65
|
|
|
26
|
-
# 或从 npm
|
|
27
|
-
openclaw plugins install @
|
|
66
|
+
# 或从 npm 安装
|
|
67
|
+
openclaw plugins install @pwddd/skills-scanner
|
|
28
68
|
|
|
29
69
|
# 重启 Gateway(Plugin 变更必须重启)
|
|
30
70
|
openclaw gateway restart
|
|
31
71
|
```
|
|
32
72
|
|
|
33
73
|
重启后 Gateway 会自动:
|
|
34
|
-
1. 创建 Python venv 并安装 `
|
|
74
|
+
1. 创建 Python venv 并安装 `requests` 库
|
|
35
75
|
2. 开始监听 Skills 目录(安装前扫描)
|
|
36
76
|
3. 在启动日志里打印是否需要注册 Cron Job
|
|
37
77
|
|
|
@@ -116,8 +156,11 @@ Plugin 启动后用 `fs.watch` 监听所有 Skills 目录。任何新 Skill 出
|
|
|
116
156
|
"skills-scanner": {
|
|
117
157
|
"enabled": true,
|
|
118
158
|
"config": {
|
|
159
|
+
"apiUrl": "http://localhost:8000", // API 服务地址
|
|
119
160
|
"scanDirs": ["~/.openclaw/skills"], // 留空自动检测
|
|
120
161
|
"behavioral": false, // 行为分析(较慢)
|
|
162
|
+
"useLLM": false, // 启用 LLM 分析
|
|
163
|
+
"policy": "balanced", // strict | balanced | permissive
|
|
121
164
|
"preInstallScan": "on", // fs.watch 安装前扫描
|
|
122
165
|
"onUnsafe": "quarantine" // quarantine | delete | warn
|
|
123
166
|
}
|
|
@@ -127,12 +170,92 @@ Plugin 启动后用 `fs.watch` 监听所有 Skills 目录。任何新 Skill 出
|
|
|
127
170
|
}
|
|
128
171
|
```
|
|
129
172
|
|
|
173
|
+
### 配置说明
|
|
174
|
+
|
|
175
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| `apiUrl` | string | `http://localhost:8000` | skill-scanner-api 服务地址 |
|
|
178
|
+
| `scanDirs` | string[] | 自动检测 | 要扫描的 Skills 目录列表 |
|
|
179
|
+
| `behavioral` | boolean | `false` | 启用行为分析(更准确但较慢) |
|
|
180
|
+
| `useLLM` | boolean | `false` | 启用 LLM 分析(需 API 服务配置 LLM) |
|
|
181
|
+
| `policy` | string | `balanced` | 扫描策略:`strict`(严格)/ `balanced`(平衡)/ `permissive`(宽松) |
|
|
182
|
+
| `preInstallScan` | string | `on` | 是否启用 fs.watch 安装前扫描 |
|
|
183
|
+
| `onUnsafe` | string | `quarantine` | 发现不安全 Skill 时的处置方式 |
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## HTTP API 端点说明
|
|
188
|
+
|
|
189
|
+
本插件使用以下 API 端点:
|
|
190
|
+
|
|
191
|
+
| 端点 | 方法 | 用途 |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| `/health` | GET | 健康检查 |
|
|
194
|
+
| `/scan-upload` | POST | 上传 ZIP 文件扫描(单个 Skill) |
|
|
195
|
+
| `/scan-batch` | POST | 批量异步扫描(服务器本地目录) |
|
|
196
|
+
| `/scan-batch/{scan_id}` | GET | 查询批量扫描结果 |
|
|
197
|
+
|
|
198
|
+
### 扫描方式
|
|
199
|
+
|
|
200
|
+
1. **单个 Skill 扫描**:使用 `/scan-upload` 端点
|
|
201
|
+
- 客户端将 Skill 目录打包成 ZIP
|
|
202
|
+
- 上传到服务器
|
|
203
|
+
- 服务器解压并扫描
|
|
204
|
+
- 返回扫描结果
|
|
205
|
+
|
|
206
|
+
2. **批量扫描(服务器本地)**:使用 `/scan-batch` 端点
|
|
207
|
+
- 适用于服务器本地目录
|
|
208
|
+
- 异步执行,返回 scan_id
|
|
209
|
+
- 轮询 `/scan-batch/{scan_id}` 获取结果
|
|
210
|
+
|
|
211
|
+
3. **批量扫描(客户端上传)**:客户端循环调用 `/scan-upload`
|
|
212
|
+
- 适用于客户端本地多个 Skills
|
|
213
|
+
- 逐个打包上传扫描
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 故障排查
|
|
218
|
+
|
|
219
|
+
### 1. 连接失败
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
✗ 无法连接到 API 服务: http://localhost:8000
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**解决方法**:
|
|
226
|
+
- 确认 `skill-scanner-api` 服务正在运行
|
|
227
|
+
- 检查配置中的 `apiUrl` 是否正确
|
|
228
|
+
- 测试连接:`curl http://localhost:8000/health`
|
|
229
|
+
|
|
230
|
+
### 2. 代理问题
|
|
231
|
+
|
|
232
|
+
如果遇到代理相关错误,插件会自动清除代理环境变量。如果仍有问题,手动清除:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 3. Python 依赖问题
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
⏳ Python 依赖尚未就绪
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**解决方法**:
|
|
245
|
+
- 检查 `uv` 是否安装:`uv --version`
|
|
246
|
+
- 手动安装依赖:
|
|
247
|
+
```bash
|
|
248
|
+
cd ~/.openclaw/extensions/skills-scanner/skills/skills-scanner
|
|
249
|
+
uv venv .venv --python 3.10
|
|
250
|
+
uv pip install --python .venv/bin/python requests>=2.31.0
|
|
251
|
+
```
|
|
252
|
+
|
|
130
253
|
---
|
|
131
254
|
|
|
132
255
|
## 发布到 npm
|
|
133
256
|
|
|
134
257
|
```bash
|
|
135
|
-
#
|
|
258
|
+
# 已发布为 @pwddd/skills-scanner
|
|
136
259
|
npm login
|
|
137
260
|
npm publish --access public
|
|
138
261
|
```
|
package/index.ts
CHANGED
|
@@ -30,7 +30,8 @@ import { promisify } from "util";
|
|
|
30
30
|
|
|
31
31
|
const execAsync = promisify(exec);
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// 使用绝对路径,避免 __dirname 的问题
|
|
34
|
+
const PLUGIN_ROOT = process.env.OPENCLAW_PLUGIN_ROOT || __dirname;
|
|
34
35
|
const SKILL_DIR = join(PLUGIN_ROOT, "skills", "skills-scanner");
|
|
35
36
|
const VENV_PYTHON = join(SKILL_DIR, ".venv", "bin", "python");
|
|
36
37
|
const SCAN_SCRIPT = join(SKILL_DIR, "scan.py");
|
|
@@ -41,10 +42,16 @@ const QUARANTINE_DIR = join(STATE_DIR, "quarantine");
|
|
|
41
42
|
// ── 型別 ──────────────────────────────────────────────────────────────────────
|
|
42
43
|
|
|
43
44
|
interface ScannerConfig {
|
|
45
|
+
/** API 服务地址 */
|
|
46
|
+
apiUrl?: string;
|
|
44
47
|
/** 扫描目录列表,默认自动检测 */
|
|
45
48
|
scanDirs?: string[];
|
|
46
49
|
/** 是否启用行为分析(较慢但更准确) */
|
|
47
50
|
behavioral?: boolean;
|
|
51
|
+
/** 是否启用 LLM 分析 */
|
|
52
|
+
useLLM?: boolean;
|
|
53
|
+
/** 扫描策略 */
|
|
54
|
+
policy?: "strict" | "balanced" | "permissive";
|
|
48
55
|
/**
|
|
49
56
|
* 安装前扫描(fs.watch):
|
|
50
57
|
* - "on" 监听所有 scanDirs(默认)
|
|
@@ -74,10 +81,10 @@ function hasUv(): boolean {
|
|
|
74
81
|
|
|
75
82
|
function isVenvReady(): boolean {
|
|
76
83
|
if (!existsSync(VENV_PYTHON)) return false;
|
|
77
|
-
|
|
78
|
-
// 检查
|
|
84
|
+
|
|
85
|
+
// 检查 requests 是否安装
|
|
79
86
|
try {
|
|
80
|
-
execSync(`"${VENV_PYTHON}" -c "import
|
|
87
|
+
execSync(`"${VENV_PYTHON}" -c "import requests"`, { stdio: 'ignore' });
|
|
81
88
|
return true;
|
|
82
89
|
} catch {
|
|
83
90
|
return false;
|
|
@@ -114,74 +121,40 @@ function saveState(s: ScanState) {
|
|
|
114
121
|
|
|
115
122
|
async function ensureDeps(logger: any): Promise<boolean> {
|
|
116
123
|
if (isVenvReady()) {
|
|
117
|
-
logger.info("[skills-scanner] Python 依赖已就绪(
|
|
124
|
+
logger.info("[skills-scanner] Python 依赖已就绪(requests 已安装)");
|
|
118
125
|
return true;
|
|
119
126
|
}
|
|
120
|
-
|
|
127
|
+
|
|
121
128
|
if (!hasUv()) {
|
|
122
129
|
logger.warn("[skills-scanner] uv 未安装:brew install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh");
|
|
123
130
|
return false;
|
|
124
131
|
}
|
|
125
|
-
|
|
126
|
-
logger.info("[skills-scanner] 正在安装
|
|
127
|
-
|
|
132
|
+
|
|
133
|
+
logger.info("[skills-scanner] 正在安装 Python 依赖...");
|
|
134
|
+
|
|
128
135
|
try {
|
|
129
|
-
// 使用 uv 创建 venv 并安装包(一步完成)
|
|
130
|
-
logger.info("[skills-scanner] 创建虚拟环境并安装依赖...");
|
|
131
136
|
const venvDir = join(SKILL_DIR, ".venv");
|
|
132
|
-
|
|
137
|
+
|
|
133
138
|
// 如果 venv 已存在,先删除
|
|
134
139
|
if (existsSync(venvDir)) {
|
|
135
140
|
logger.info("[skills-scanner] 清理旧的虚拟环境...");
|
|
136
141
|
rmSync(venvDir, { recursive: true, force: true });
|
|
137
142
|
}
|
|
138
|
-
|
|
139
|
-
//
|
|
143
|
+
|
|
144
|
+
// 创建 venv
|
|
140
145
|
await execAsync(`uv venv "${venvDir}" --python 3.10`);
|
|
141
146
|
logger.info("[skills-scanner] 虚拟环境创建完成");
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
logger.info("[skills-scanner] 安装
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const installResult = await execAsync(installCmd);
|
|
149
|
-
logger.info(`[skills-scanner] 安装输出: ${installResult.stdout.trim()}`);
|
|
150
|
-
if (installResult.stderr) {
|
|
151
|
-
logger.warn(`[skills-scanner] 安装警告: ${installResult.stderr.trim()}`);
|
|
152
|
-
}
|
|
153
|
-
|
|
147
|
+
|
|
148
|
+
// 安装 requests
|
|
149
|
+
logger.info("[skills-scanner] 安装 requests...");
|
|
150
|
+
await execAsync(`uv pip install --python "${VENV_PYTHON}" requests>=2.31.0`);
|
|
151
|
+
|
|
154
152
|
// 验证安装
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
logger.info(`[skills-scanner] ✅ 验证成功: ${verifyResult.trim()}`);
|
|
159
|
-
return true;
|
|
160
|
-
} catch (verifyErr: any) {
|
|
161
|
-
logger.error("[skills-scanner] ❌ 验证失败");
|
|
162
|
-
logger.error(`[skills-scanner] 错误: ${verifyErr.message}`);
|
|
163
|
-
|
|
164
|
-
// 尝试手动检查文件
|
|
165
|
-
try {
|
|
166
|
-
const sitePackages = join(venvDir, "lib");
|
|
167
|
-
logger.debug(`[skills-scanner] 检查 site-packages: ${sitePackages}`);
|
|
168
|
-
if (existsSync(sitePackages)) {
|
|
169
|
-
const { stdout } = await execAsync(`find "${sitePackages}" -name "skill_scanner*" -type d`);
|
|
170
|
-
logger.debug(`[skills-scanner] 找到的包: ${stdout.trim() || "无"}`);
|
|
171
|
-
}
|
|
172
|
-
} catch {}
|
|
173
|
-
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
153
|
+
execSync(`"${VENV_PYTHON}" -c "import requests"`, { stdio: 'ignore' });
|
|
154
|
+
logger.info("[skills-scanner] ✅ 依赖安装完成");
|
|
155
|
+
return true;
|
|
176
156
|
} catch (err: any) {
|
|
177
157
|
logger.error(`[skills-scanner] ⚠️ 依赖安装失败: ${err.message}`);
|
|
178
|
-
if (err.stdout) logger.error(`[skills-scanner] stdout: ${err.stdout}`);
|
|
179
|
-
if (err.stderr) logger.error(`[skills-scanner] stderr: ${err.stderr}`);
|
|
180
|
-
logger.error(`[skills-scanner] 请手动运行:`);
|
|
181
|
-
logger.error(`[skills-scanner] cd ~/.openclaw/extensions/skills-scanner/skills/skills-scanner`);
|
|
182
|
-
logger.error(`[skills-scanner] rm -rf .venv`);
|
|
183
|
-
logger.error(`[skills-scanner] uv venv .venv --python 3.10`);
|
|
184
|
-
logger.error(`[skills-scanner] uv pip install --python .venv/bin/python cisco-ai-skill-scanner`);
|
|
185
158
|
return false;
|
|
186
159
|
}
|
|
187
160
|
}
|
|
@@ -189,26 +162,53 @@ async function ensureDeps(logger: any): Promise<boolean> {
|
|
|
189
162
|
async function runScan(
|
|
190
163
|
mode: "scan" | "batch",
|
|
191
164
|
target: string,
|
|
192
|
-
opts: { detailed?: boolean; behavioral?: boolean; recursive?: boolean; jsonOut?: string } = {}
|
|
165
|
+
opts: { detailed?: boolean; behavioral?: boolean; recursive?: boolean; jsonOut?: string; apiUrl?: string; useLLM?: boolean; policy?: string } = {}
|
|
193
166
|
): Promise<{ exitCode: number; output: string }> {
|
|
194
167
|
const args = [mode, target];
|
|
195
168
|
if (opts.detailed) args.push("--detailed");
|
|
196
169
|
if (opts.behavioral) args.push("--behavioral");
|
|
197
170
|
if (opts.recursive) args.push("--recursive");
|
|
171
|
+
if (opts.useLLM) args.push("--llm");
|
|
172
|
+
if (opts.policy) args.push("--policy", opts.policy);
|
|
198
173
|
if (opts.jsonOut) args.push("--json", opts.jsonOut);
|
|
174
|
+
if (opts.apiUrl) args.unshift("--api-url", opts.apiUrl);
|
|
199
175
|
|
|
200
176
|
const cmd = `"${VENV_PYTHON}" "${SCAN_SCRIPT}" ${args.map(a => `"${a}"`).join(" ")}`;
|
|
177
|
+
|
|
178
|
+
// 调试日志
|
|
179
|
+
console.log(`[skills-scanner] 执行命令: ${cmd}`);
|
|
180
|
+
|
|
201
181
|
try {
|
|
202
|
-
|
|
182
|
+
// 清除代理环境变量,避免连接问题
|
|
183
|
+
const env = { ...process.env };
|
|
184
|
+
delete env.http_proxy;
|
|
185
|
+
delete env.https_proxy;
|
|
186
|
+
delete env.HTTP_PROXY;
|
|
187
|
+
delete env.HTTPS_PROXY;
|
|
188
|
+
delete env.all_proxy;
|
|
189
|
+
delete env.ALL_PROXY;
|
|
190
|
+
|
|
191
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
192
|
+
timeout: 180_000,
|
|
193
|
+
env
|
|
194
|
+
});
|
|
203
195
|
return { exitCode: 0, output: (stdout + stderr).trim() };
|
|
204
196
|
} catch (err: any) {
|
|
197
|
+
console.error(`[skills-scanner] 命令执行失败: ${err.message}`);
|
|
205
198
|
return { exitCode: err.code ?? 1, output: (err.stdout + err.stderr || "").trim() || err.message };
|
|
206
199
|
}
|
|
207
200
|
}
|
|
208
201
|
|
|
209
202
|
// ── 日报 ──────────────────────────────────────────────────────────────────────
|
|
210
203
|
|
|
211
|
-
async function buildDailyReport(
|
|
204
|
+
async function buildDailyReport(
|
|
205
|
+
dirs: string[],
|
|
206
|
+
behavioral: boolean,
|
|
207
|
+
apiUrl: string,
|
|
208
|
+
useLLM: boolean,
|
|
209
|
+
policy: string,
|
|
210
|
+
logger: any
|
|
211
|
+
): Promise<string> {
|
|
212
212
|
const now = new Date();
|
|
213
213
|
const dateStr = now.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
|
|
214
214
|
const timeStr = now.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
@@ -223,7 +223,14 @@ async function buildDailyReport(dirs: string[], behavioral: boolean, logger: any
|
|
|
223
223
|
const expanded = expandPath(dir);
|
|
224
224
|
if (!existsSync(expanded)) continue;
|
|
225
225
|
const tmpJson = join(STATE_DIR, `tmp-${Date.now()}.json`);
|
|
226
|
-
await runScan("batch", expanded, {
|
|
226
|
+
await runScan("batch", expanded, {
|
|
227
|
+
behavioral,
|
|
228
|
+
recursive: true,
|
|
229
|
+
jsonOut: tmpJson,
|
|
230
|
+
apiUrl,
|
|
231
|
+
useLLM,
|
|
232
|
+
policy
|
|
233
|
+
});
|
|
227
234
|
try {
|
|
228
235
|
const rows: any[] = JSON.parse(readFileSync(tmpJson, "utf-8"));
|
|
229
236
|
try { rmSync(tmpJson); } catch {}
|
|
@@ -272,6 +279,9 @@ async function handleNewSkill(
|
|
|
272
279
|
skillPath: string,
|
|
273
280
|
onUnsafe: "quarantine" | "delete" | "warn",
|
|
274
281
|
behavioral: boolean,
|
|
282
|
+
apiUrl: string,
|
|
283
|
+
useLLM: boolean,
|
|
284
|
+
policy: string,
|
|
275
285
|
notifyFn: (msg: string) => void,
|
|
276
286
|
logger: any
|
|
277
287
|
) {
|
|
@@ -280,7 +290,13 @@ async function handleNewSkill(
|
|
|
280
290
|
logger.info(`[skills-scanner] 🔍 检测到新 Skill,开始安装前扫描: ${name}`);
|
|
281
291
|
notifyFn(`🔍 检测到新 Skill \`${name}\`,正在安全扫描...`);
|
|
282
292
|
|
|
283
|
-
const res = await runScan("scan", skillPath, {
|
|
293
|
+
const res = await runScan("scan", skillPath, {
|
|
294
|
+
behavioral,
|
|
295
|
+
detailed: true,
|
|
296
|
+
apiUrl,
|
|
297
|
+
useLLM,
|
|
298
|
+
policy
|
|
299
|
+
});
|
|
284
300
|
|
|
285
301
|
if (res.exitCode === 0) {
|
|
286
302
|
notifyFn(`✅ \`${name}\` 安全检查通过,可以正常使用。`);
|
|
@@ -318,6 +334,9 @@ function startWatcher(
|
|
|
318
334
|
dirs: string[],
|
|
319
335
|
onUnsafe: "quarantine" | "delete" | "warn",
|
|
320
336
|
behavioral: boolean,
|
|
337
|
+
apiUrl: string,
|
|
338
|
+
useLLM: boolean,
|
|
339
|
+
policy: string,
|
|
321
340
|
notifyFn: (msg: string) => void,
|
|
322
341
|
logger: any
|
|
323
342
|
): () => void {
|
|
@@ -338,7 +357,7 @@ function startWatcher(
|
|
|
338
357
|
if (prev) clearTimeout(prev);
|
|
339
358
|
timers.set(skillPath, setTimeout(() => {
|
|
340
359
|
timers.delete(skillPath);
|
|
341
|
-
handleNewSkill(skillPath, onUnsafe, behavioral, notifyFn, logger);
|
|
360
|
+
handleNewSkill(skillPath, onUnsafe, behavioral, apiUrl, useLLM, policy, notifyFn, logger);
|
|
342
361
|
}, 500));
|
|
343
362
|
});
|
|
344
363
|
});
|
|
@@ -355,17 +374,24 @@ function startWatcher(
|
|
|
355
374
|
|
|
356
375
|
export default function register(api: any) {
|
|
357
376
|
const cfg: ScannerConfig = api.config?.plugins?.entries?.["skills-scanner"]?.config ?? {};
|
|
377
|
+
const apiUrl = cfg.apiUrl ?? "http://localhost:8000";
|
|
358
378
|
const scanDirs = (cfg.scanDirs?.map(expandPath) ?? []).filter(existsSync).length > 0
|
|
359
379
|
? (cfg.scanDirs!.map(expandPath))
|
|
360
380
|
: defaultScanDirs();
|
|
361
381
|
const behavioral = cfg.behavioral ?? false;
|
|
382
|
+
const useLLM = cfg.useLLM ?? false;
|
|
383
|
+
const policy = cfg.policy ?? "balanced";
|
|
362
384
|
const preInstallScan = cfg.preInstallScan ?? "on";
|
|
363
385
|
const onUnsafe = cfg.onUnsafe ?? "quarantine";
|
|
364
386
|
|
|
365
387
|
api.logger.info("[skills-scanner] ═══════════════════════════════════════");
|
|
366
388
|
api.logger.info("[skills-scanner] Plugin 正在加载...");
|
|
389
|
+
api.logger.info(`[skills-scanner] API 服务地址: ${apiUrl}`);
|
|
390
|
+
api.logger.info(`[skills-scanner] PLUGIN_ROOT: ${PLUGIN_ROOT}`);
|
|
391
|
+
api.logger.info(`[skills-scanner] SKILL_DIR: ${SKILL_DIR}`);
|
|
392
|
+
api.logger.info(`[skills-scanner] VENV_PYTHON: ${VENV_PYTHON}`);
|
|
393
|
+
api.logger.info(`[skills-scanner] SCAN_SCRIPT: ${SCAN_SCRIPT}`);
|
|
367
394
|
api.logger.info(`[skills-scanner] 扫描目录: ${scanDirs.join(", ")}`);
|
|
368
|
-
api.logger.info(`[skills-scanner] Python venv 路径: ${VENV_PYTHON}`);
|
|
369
395
|
api.logger.info(`[skills-scanner] Python 依赖状态: ${isVenvReady() ? "✅ 已就绪" : "❌ 未安装"}`);
|
|
370
396
|
|
|
371
397
|
// 立即尝试安装依赖(不等待 service start)
|
|
@@ -409,7 +435,7 @@ export default function register(api: any) {
|
|
|
409
435
|
|
|
410
436
|
if (preInstallScan === "on" && scanDirs.length > 0) {
|
|
411
437
|
api.logger.info(`[skills-scanner] 📁 启动文件监控: ${scanDirs.length} 个目录`);
|
|
412
|
-
stopWatcher = startWatcher(scanDirs, onUnsafe, behavioral, persistWatcherAlert, api.logger);
|
|
438
|
+
stopWatcher = startWatcher(scanDirs, onUnsafe, behavioral, apiUrl, useLLM, policy, persistWatcherAlert, api.logger);
|
|
413
439
|
api.logger.info("[skills-scanner] ✅ 文件监控已启动");
|
|
414
440
|
} else {
|
|
415
441
|
api.logger.info("[skills-scanner] ⏭️ 安装前扫描已禁用");
|
|
@@ -463,14 +489,24 @@ export default function register(api: any) {
|
|
|
463
489
|
handler: async (ctx: any) => {
|
|
464
490
|
const raw = (ctx.args ?? "").trim();
|
|
465
491
|
if (!raw) return { text: "用法:`/scan-skill <路径> [--detailed] [--behavioral]`" };
|
|
466
|
-
|
|
492
|
+
|
|
493
|
+
// 先检查依赖
|
|
494
|
+
if (!isVenvReady()) {
|
|
495
|
+
return { text: "⏳ Python 依赖尚未就绪,请稍后重试或查看日志" };
|
|
496
|
+
}
|
|
467
497
|
|
|
468
498
|
const parts = raw.split(/\s+/);
|
|
469
499
|
const skillPath = expandPath(parts.find(p => !p.startsWith("--")) ?? "");
|
|
470
500
|
const detailed = parts.includes("--detailed");
|
|
471
501
|
const useBehav = parts.includes("--behavioral") || behavioral;
|
|
472
502
|
|
|
473
|
-
const res = await runScan("scan", skillPath, {
|
|
503
|
+
const res = await runScan("scan", skillPath, {
|
|
504
|
+
detailed,
|
|
505
|
+
behavioral: useBehav,
|
|
506
|
+
apiUrl,
|
|
507
|
+
useLLM,
|
|
508
|
+
policy
|
|
509
|
+
});
|
|
474
510
|
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
475
511
|
return { text: `${icon} 扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
476
512
|
},
|
|
@@ -493,7 +529,14 @@ export default function register(api: any) {
|
|
|
493
529
|
const detailed = parts.includes("--detailed");
|
|
494
530
|
const useBehav = parts.includes("--behavioral") || behavioral;
|
|
495
531
|
|
|
496
|
-
const res = await runScan("batch", dirPath, {
|
|
532
|
+
const res = await runScan("batch", dirPath, {
|
|
533
|
+
recursive,
|
|
534
|
+
detailed,
|
|
535
|
+
behavioral: useBehav,
|
|
536
|
+
apiUrl,
|
|
537
|
+
useLLM,
|
|
538
|
+
policy
|
|
539
|
+
});
|
|
497
540
|
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
498
541
|
return { text: `${icon} 批量扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
499
542
|
},
|
|
@@ -508,7 +551,7 @@ export default function register(api: any) {
|
|
|
508
551
|
handler: async (_ctx: any) => {
|
|
509
552
|
if (!isVenvReady()) return { text: "⏳ Python 依赖尚未就绪,请稍后重试" };
|
|
510
553
|
if (scanDirs.length === 0) return { text: "⚠️ 未找到可扫描目录,请检查配置" };
|
|
511
|
-
const report = await buildDailyReport(scanDirs, behavioral, api.logger);
|
|
554
|
+
const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, api.logger);
|
|
512
555
|
return { text: report };
|
|
513
556
|
},
|
|
514
557
|
});
|
|
@@ -576,14 +619,21 @@ export default function register(api: any) {
|
|
|
576
619
|
const { path: p, mode = "scan", recursive = false, detailed = false } = params ?? {};
|
|
577
620
|
if (!p) return respond(false, { error: "缺少 path 参数" });
|
|
578
621
|
if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
|
|
579
|
-
const res = await runScan(mode === "batch" ? "batch" : "scan", expandPath(p), {
|
|
622
|
+
const res = await runScan(mode === "batch" ? "batch" : "scan", expandPath(p), {
|
|
623
|
+
recursive,
|
|
624
|
+
detailed,
|
|
625
|
+
behavioral,
|
|
626
|
+
apiUrl,
|
|
627
|
+
useLLM,
|
|
628
|
+
policy
|
|
629
|
+
});
|
|
580
630
|
respond(res.exitCode === 0, { output: res.output, exitCode: res.exitCode, is_safe: res.exitCode === 0 });
|
|
581
631
|
});
|
|
582
632
|
|
|
583
633
|
api.registerGatewayMethod("skillsScanner.report", async ({ respond }: any) => {
|
|
584
634
|
if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
|
|
585
635
|
if (scanDirs.length === 0) return respond(false, { error: "未找到可扫描目录" });
|
|
586
|
-
const report = await buildDailyReport(scanDirs, behavioral, api.logger);
|
|
636
|
+
const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, api.logger);
|
|
587
637
|
respond(true, { report, state: loadState() });
|
|
588
638
|
});
|
|
589
639
|
|
|
@@ -596,7 +646,12 @@ export default function register(api: any) {
|
|
|
596
646
|
.option("--detailed", "显示所有 findings")
|
|
597
647
|
.option("--behavioral", "启用行为分析")
|
|
598
648
|
.action(async (p: string, opts: any) => {
|
|
599
|
-
const res = await runScan("scan", expandPath(p),
|
|
649
|
+
const res = await runScan("scan", expandPath(p), {
|
|
650
|
+
...opts,
|
|
651
|
+
apiUrl,
|
|
652
|
+
useLLM,
|
|
653
|
+
policy
|
|
654
|
+
});
|
|
600
655
|
console.log(res.output);
|
|
601
656
|
process.exit(res.exitCode);
|
|
602
657
|
});
|
|
@@ -607,7 +662,12 @@ export default function register(api: any) {
|
|
|
607
662
|
.option("--detailed", "显示所有 findings")
|
|
608
663
|
.option("--behavioral", "启用行为分析")
|
|
609
664
|
.action(async (d: string, opts: any) => {
|
|
610
|
-
const res = await runScan("batch", expandPath(d),
|
|
665
|
+
const res = await runScan("batch", expandPath(d), {
|
|
666
|
+
...opts,
|
|
667
|
+
apiUrl,
|
|
668
|
+
useLLM,
|
|
669
|
+
policy
|
|
670
|
+
});
|
|
611
671
|
console.log(res.output);
|
|
612
672
|
process.exit(res.exitCode);
|
|
613
673
|
});
|
|
@@ -615,7 +675,7 @@ export default function register(api: any) {
|
|
|
615
675
|
cmd.command("report")
|
|
616
676
|
.description("立即执行全量扫描并打印日报")
|
|
617
677
|
.action(async () => {
|
|
618
|
-
const report = await buildDailyReport(scanDirs, behavioral, console);
|
|
678
|
+
const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, console);
|
|
619
679
|
console.log(report);
|
|
620
680
|
});
|
|
621
681
|
}, { commands: ["skills-scan"] });
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "skills-scanner",
|
|
3
3
|
"name": "Skills Security Scanner",
|
|
4
|
-
"description": "Skills
|
|
4
|
+
"description": "Skills 安全扫描:通过 HTTP API 调用远程 cisco-ai-skill-scanner 服务",
|
|
5
5
|
"skills": ["./skills"],
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
10
|
+
"apiUrl": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "http://localhost:8000",
|
|
13
|
+
"description": "cisco-ai-skill-scanner API 服务地址"
|
|
14
|
+
},
|
|
10
15
|
"scanDirs": {
|
|
11
16
|
"type": "array",
|
|
12
17
|
"items": { "type": "string" },
|
|
@@ -15,7 +20,18 @@
|
|
|
15
20
|
"behavioral": {
|
|
16
21
|
"type": "boolean",
|
|
17
22
|
"default": false,
|
|
18
|
-
"description": "
|
|
23
|
+
"description": "启用行为分析(更准确但较慢)"
|
|
24
|
+
},
|
|
25
|
+
"useLLM": {
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"default": false,
|
|
28
|
+
"description": "启用 LLM 分析(需要 API 服务配置 LLM)"
|
|
29
|
+
},
|
|
30
|
+
"policy": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"enum": ["strict", "balanced", "permissive"],
|
|
33
|
+
"default": "balanced",
|
|
34
|
+
"description": "扫描策略:strict(严格)/ balanced(平衡)/ permissive(宽松)"
|
|
19
35
|
},
|
|
20
36
|
"preInstallScan": {
|
|
21
37
|
"type": "string",
|
|
@@ -32,8 +48,11 @@
|
|
|
32
48
|
}
|
|
33
49
|
},
|
|
34
50
|
"uiHints": {
|
|
51
|
+
"apiUrl": { "label": "API 服务地址", "placeholder": "http://localhost:8000" },
|
|
35
52
|
"scanDirs": { "label": "扫描目录(留空自动检测)" },
|
|
36
|
-
"behavioral": { "label": "
|
|
53
|
+
"behavioral": { "label": "启用行为分析" },
|
|
54
|
+
"useLLM": { "label": "启用 LLM 分析" },
|
|
55
|
+
"policy": { "label": "扫描策略" },
|
|
37
56
|
"preInstallScan": { "label": "安装前扫描(fs.watch)" },
|
|
38
57
|
"onUnsafe": { "label": "不安全 Skill 的处置方式" }
|
|
39
58
|
}
|
package/package.json
CHANGED
|
@@ -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,22 +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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
)
|
|
27
|
-
except ImportError:
|
|
28
|
-
print("❌ cisco-ai-skill-scanner 未安装。")
|
|
29
|
-
print(" 请运行: uv pip install cisco-ai-skill-scanner")
|
|
25
|
+
import requests
|
|
26
|
+
except ImportError as e:
|
|
27
|
+
print("❌ requests 未安装。")
|
|
28
|
+
print(f" 导入错误: {e}")
|
|
29
|
+
print(" 请运行: uv pip install requests")
|
|
30
30
|
sys.exit(1)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# ── 配置 ──────────────────────────────────────────────────────────────────────
|
|
34
|
+
DEFAULT_API_URL = os.getenv("SKILL_SCANNER_API_URL", "http://localhost:8000")
|
|
35
|
+
REQUEST_TIMEOUT = 180 # 3 分钟超时
|
|
36
|
+
|
|
37
|
+
|
|
33
38
|
# ── 颜色输出 ──────────────────────────────────────────────────────────────────
|
|
34
39
|
USE_COLOR = sys.stdout.isatty()
|
|
35
40
|
|
|
@@ -44,231 +49,428 @@ BOLD = lambda t: c(t, "1")
|
|
|
44
49
|
DIM = lambda t: c(t, "2")
|
|
45
50
|
|
|
46
51
|
|
|
47
|
-
# ──
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
sev = (sev or "INFO").upper()
|
|
58
|
-
color = SEVERITY_COLORS.get(sev, CYAN)
|
|
59
|
-
return color(f"[{sev}]")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# ── 构建 Scanner ──────────────────────────────────────────────────────────────
|
|
63
|
-
def build_scanner(use_behavioral: bool = False, use_llm: bool = False) -> SkillScanner:
|
|
64
|
-
analyzers = [StaticAnalyzer(), PipelineAnalyzer()]
|
|
65
|
-
if use_behavioral:
|
|
66
|
-
analyzers.append(BehavioralAnalyzer())
|
|
67
|
-
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) -> bool:
|
|
61
|
+
"""健康检查"""
|
|
68
62
|
try:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
63
|
+
response = self.session.get(f"{self.base_url}/health", timeout=5)
|
|
64
|
+
return response.status_code == 200
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def scan_upload(
|
|
69
|
+
self,
|
|
70
|
+
skill_path: str,
|
|
71
|
+
policy: str = "balanced",
|
|
72
|
+
use_llm: bool = False,
|
|
73
|
+
use_behavioral: bool = False,
|
|
74
|
+
use_virustotal: bool = False,
|
|
75
|
+
use_aidefense: bool = False,
|
|
76
|
+
use_trigger: bool = False,
|
|
77
|
+
enable_meta: bool = False,
|
|
78
|
+
vt_api_key: Optional[str] = None,
|
|
79
|
+
aidefense_api_key: Optional[str] = None
|
|
80
|
+
) -> Dict[str, Any]:
|
|
81
|
+
"""上传 ZIP 文件扫描(单个 Skill)
|
|
82
|
+
|
|
83
|
+
对应 API: POST /scan-upload
|
|
84
|
+
- 上传 ZIP 文件
|
|
85
|
+
- 服务器解压并查找 SKILL.md
|
|
86
|
+
- 返回扫描结果
|
|
87
|
+
"""
|
|
88
|
+
# 创建临时 ZIP 文件
|
|
89
|
+
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_zip:
|
|
90
|
+
zip_path = tmp_zip.name
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# 打包 Skill 目录
|
|
94
|
+
self._create_zip(skill_path, zip_path)
|
|
95
|
+
|
|
96
|
+
# 准备请求
|
|
97
|
+
with open(zip_path, 'rb') as f:
|
|
98
|
+
files = {'file': (os.path.basename(skill_path) + '.zip', f, 'application/zip')}
|
|
99
|
+
data = {
|
|
100
|
+
'policy': policy,
|
|
101
|
+
'use_llm': str(use_llm).lower(),
|
|
102
|
+
'use_behavioral': str(use_behavioral).lower(),
|
|
103
|
+
'use_virustotal': str(use_virustotal).lower(),
|
|
104
|
+
'use_aidefense': str(use_aidefense).lower(),
|
|
105
|
+
'use_trigger': str(use_trigger).lower(),
|
|
106
|
+
'enable_meta': str(enable_meta).lower()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# 添加认证头
|
|
110
|
+
headers = {}
|
|
111
|
+
if vt_api_key:
|
|
112
|
+
headers['X-VirusTotal-Key'] = vt_api_key
|
|
113
|
+
if aidefense_api_key:
|
|
114
|
+
headers['X-AIDefense-Key'] = aidefense_api_key
|
|
115
|
+
|
|
116
|
+
response = self.session.post(
|
|
117
|
+
f"{self.base_url}/scan-upload",
|
|
118
|
+
files=files,
|
|
119
|
+
data=data,
|
|
120
|
+
headers=headers,
|
|
121
|
+
timeout=REQUEST_TIMEOUT
|
|
122
|
+
)
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
return response.json()
|
|
125
|
+
finally:
|
|
126
|
+
# 清理临时文件
|
|
127
|
+
if os.path.exists(zip_path):
|
|
128
|
+
os.unlink(zip_path)
|
|
129
|
+
|
|
130
|
+
def scan_batch_upload(
|
|
131
|
+
self,
|
|
132
|
+
skill_paths: List[str],
|
|
133
|
+
policy: str = "balanced",
|
|
134
|
+
use_llm: bool = False,
|
|
135
|
+
use_behavioral: bool = False,
|
|
136
|
+
max_concurrent: int = 3
|
|
137
|
+
) -> List[Dict[str, Any]]:
|
|
138
|
+
"""批量上传多个 Skill(客户端循环上传)
|
|
139
|
+
|
|
140
|
+
注意:API 不支持批量上传,这里是客户端实现
|
|
141
|
+
"""
|
|
142
|
+
results = []
|
|
143
|
+
|
|
144
|
+
for i, skill_path in enumerate(skill_paths, 1):
|
|
145
|
+
print(f"[{i}/{len(skill_paths)}] 正在扫描: {skill_path}")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = self.scan_upload(
|
|
149
|
+
skill_path,
|
|
150
|
+
policy=policy,
|
|
151
|
+
use_llm=use_llm,
|
|
152
|
+
use_behavioral=use_behavioral
|
|
153
|
+
)
|
|
154
|
+
results.append({
|
|
155
|
+
'path': skill_path,
|
|
156
|
+
'success': True,
|
|
157
|
+
'result': result
|
|
158
|
+
})
|
|
159
|
+
status = "✓" if result.get('is_safe', False) else "✗"
|
|
160
|
+
print(f" {status} {result.get('skill_name', 'Unknown')}: {result.get('findings_count', 0)} 个发现")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
results.append({
|
|
163
|
+
'path': skill_path,
|
|
164
|
+
'success': False,
|
|
165
|
+
'error': str(e)
|
|
166
|
+
})
|
|
167
|
+
print(f" ✗ 失败: {e}")
|
|
168
|
+
|
|
169
|
+
return results
|
|
170
|
+
|
|
171
|
+
def scan_batch(
|
|
172
|
+
self,
|
|
173
|
+
skills_dir: str,
|
|
174
|
+
recursive: bool = False,
|
|
175
|
+
check_overlap: bool = False,
|
|
176
|
+
policy: str = "balanced",
|
|
177
|
+
use_llm: bool = False,
|
|
178
|
+
use_behavioral: bool = False
|
|
179
|
+
) -> str:
|
|
180
|
+
"""批量异步扫描(服务器本地目录),返回 scan_id
|
|
181
|
+
|
|
182
|
+
对应 API: POST /scan-batch
|
|
183
|
+
- 扫描服务器本地目录中的多个技能
|
|
184
|
+
- 异步执行,返回 scan_id
|
|
185
|
+
- 需要轮询 GET /scan-batch/{scan_id} 获取结果
|
|
186
|
+
"""
|
|
187
|
+
data = {
|
|
188
|
+
'skills_directory': skills_dir,
|
|
189
|
+
'recursive': recursive,
|
|
190
|
+
'check_overlap': check_overlap,
|
|
191
|
+
'policy': policy,
|
|
192
|
+
'use_llm': use_llm,
|
|
193
|
+
'use_behavioral': use_behavioral
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
response = self.session.post(
|
|
197
|
+
f"{self.base_url}/scan-batch",
|
|
198
|
+
json=data,
|
|
199
|
+
timeout=30
|
|
200
|
+
)
|
|
201
|
+
response.raise_for_status()
|
|
202
|
+
return response.json()['scan_id']
|
|
203
|
+
|
|
204
|
+
def get_batch_result(self, scan_id: str) -> Dict[str, Any]:
|
|
205
|
+
"""获取批量扫描结果"""
|
|
206
|
+
response = self.session.get(
|
|
207
|
+
f"{self.base_url}/scan-batch/{scan_id}",
|
|
208
|
+
timeout=10
|
|
209
|
+
)
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
return response.json()
|
|
212
|
+
|
|
213
|
+
def wait_for_batch(self, scan_id: str, poll_interval: int = 5) -> Dict[str, Any]:
|
|
214
|
+
"""等待批量扫描完成"""
|
|
215
|
+
while True:
|
|
216
|
+
result = self.get_batch_result(scan_id)
|
|
217
|
+
status = result.get('status')
|
|
218
|
+
|
|
219
|
+
if status == 'completed':
|
|
220
|
+
return result['result']
|
|
221
|
+
elif status == 'failed':
|
|
222
|
+
raise Exception(f"批量扫描失败: {result.get('error')}")
|
|
223
|
+
|
|
224
|
+
time.sleep(poll_interval)
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _create_zip(source_dir: str, zip_path: str):
|
|
228
|
+
"""创建 ZIP 文件"""
|
|
229
|
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
230
|
+
source_path = Path(source_dir)
|
|
231
|
+
for file_path in source_path.rglob('*'):
|
|
232
|
+
if file_path.is_file():
|
|
233
|
+
arcname = file_path.relative_to(source_path.parent)
|
|
234
|
+
zipf.write(file_path, arcname)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── 格式化输出 ────────────────────────────────────────────────────────────────
|
|
238
|
+
def format_scan_result(result: Dict[str, Any], detailed: bool = False) -> str:
|
|
239
|
+
"""格式化扫描结果"""
|
|
240
|
+
lines = []
|
|
241
|
+
|
|
242
|
+
# 基本信息
|
|
243
|
+
skill_name = result.get('skill_name', 'Unknown')
|
|
244
|
+
is_safe = result.get('is_safe', False)
|
|
245
|
+
max_severity = result.get('max_severity', 'NONE')
|
|
246
|
+
findings_count = result.get('findings_count', 0)
|
|
247
|
+
|
|
106
248
|
# 状态行
|
|
107
|
-
if is_safe
|
|
108
|
-
|
|
249
|
+
status_icon = GREEN("✓") if is_safe else RED("✗")
|
|
250
|
+
lines.append(f"{status_icon} {BOLD(skill_name)}")
|
|
251
|
+
lines.append(f" 严重性: {_severity_color(max_severity)}")
|
|
252
|
+
lines.append(f" 发现数: {findings_count}")
|
|
253
|
+
|
|
254
|
+
# 详细发现
|
|
255
|
+
if detailed and findings_count > 0:
|
|
256
|
+
findings = result.get('findings', [])
|
|
257
|
+
lines.append("")
|
|
258
|
+
lines.append(BOLD("发现详情:"))
|
|
259
|
+
for i, finding in enumerate(findings[:10], 1): # 最多显示 10 条
|
|
260
|
+
severity = finding.get('severity', 'UNKNOWN')
|
|
261
|
+
category = finding.get('category', 'Unknown')
|
|
262
|
+
description = finding.get('description', 'No description')
|
|
263
|
+
lines.append(f" {i}. [{_severity_color(severity)}] {category}")
|
|
264
|
+
lines.append(f" {description}")
|
|
265
|
+
|
|
266
|
+
if len(findings) > 10:
|
|
267
|
+
lines.append(f" ... 还有 {len(findings) - 10} 条发现")
|
|
268
|
+
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_batch_result(result: Dict[str, Any]) -> str:
|
|
273
|
+
"""格式化批量扫描结果"""
|
|
274
|
+
lines = []
|
|
275
|
+
|
|
276
|
+
total = result.get('total_skills_scanned', 0)
|
|
277
|
+
safe = result.get('safe_skills', 0)
|
|
278
|
+
unsafe = result.get('unsafe_skills', 0)
|
|
279
|
+
|
|
280
|
+
lines.append(BOLD("批量扫描结果"))
|
|
281
|
+
lines.append(f" 总计: {total} 个 Skills")
|
|
282
|
+
lines.append(f" 安全: {GREEN(str(safe))}")
|
|
283
|
+
lines.append(f" 问题: {RED(str(unsafe))}")
|
|
284
|
+
|
|
285
|
+
# 问题 Skills 列表
|
|
286
|
+
if unsafe > 0:
|
|
287
|
+
skills = result.get('skills', [])
|
|
288
|
+
unsafe_skills = [s for s in skills if not s.get('is_safe', True)]
|
|
289
|
+
lines.append("")
|
|
290
|
+
lines.append(BOLD("问题 Skills:"))
|
|
291
|
+
for skill in unsafe_skills[:10]:
|
|
292
|
+
name = skill.get('skill_name', 'Unknown')
|
|
293
|
+
severity = skill.get('max_severity', 'UNKNOWN')
|
|
294
|
+
count = skill.get('findings_count', 0)
|
|
295
|
+
lines.append(f" • {name} [{_severity_color(severity)}] - {count} 条发现")
|
|
296
|
+
|
|
297
|
+
return "\n".join(lines)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _severity_color(severity: str) -> str:
|
|
301
|
+
"""严重性着色"""
|
|
302
|
+
severity_upper = severity.upper()
|
|
303
|
+
if severity_upper in ('CRITICAL', 'HIGH'):
|
|
304
|
+
return RED(severity_upper)
|
|
305
|
+
elif severity_upper == 'MEDIUM':
|
|
306
|
+
return YELLOW(severity_upper)
|
|
307
|
+
elif severity_upper == 'LOW':
|
|
308
|
+
return CYAN(severity_upper)
|
|
109
309
|
else:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
print(f" 状态: {status} | 最高严重级别: {severity_label(max_sev)} | 发现: {len(findings)} 条")
|
|
310
|
+
return DIM(severity_upper)
|
|
113
311
|
|
|
114
|
-
# 详细 findings
|
|
115
|
-
if findings and getattr(args, "detailed", False):
|
|
116
|
-
print(f"\n {BOLD('发现详情:')}")
|
|
117
|
-
for i, f in enumerate(findings, 1):
|
|
118
|
-
sev = str(getattr(f, "severity", "INFO")).upper()
|
|
119
|
-
name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
|
|
120
|
-
desc = getattr(f, "description", getattr(f, "message", ""))
|
|
121
|
-
loc = getattr(f, "location", getattr(f, "file", ""))
|
|
122
|
-
print(f" {DIM(str(i)+'.')} {severity_label(sev)} {BOLD(name)}")
|
|
123
|
-
if desc:
|
|
124
|
-
print(f" {desc}")
|
|
125
|
-
if loc:
|
|
126
|
-
print(f" {DIM('位置: ' + str(loc))}")
|
|
127
|
-
elif findings and not getattr(args, "detailed", False):
|
|
128
|
-
# 非 detailed 模式只显示 HIGH/CRITICAL
|
|
129
|
-
serious = [f for f in findings if str(getattr(f, "severity", "")).upper() in ("CRITICAL", "HIGH")]
|
|
130
|
-
if serious:
|
|
131
|
-
print(f"\n {BOLD('HIGH/CRITICAL 问题:')}")
|
|
132
|
-
for f in serious:
|
|
133
|
-
sev = str(getattr(f, "severity", "HIGH")).upper()
|
|
134
|
-
name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
|
|
135
|
-
desc = getattr(f, "description", getattr(f, "message", ""))
|
|
136
|
-
print(f" {severity_label(sev)} {BOLD(name)}: {desc}")
|
|
137
312
|
|
|
138
|
-
|
|
139
|
-
"path": str(skill_path),
|
|
140
|
-
"name": skill_path.name,
|
|
141
|
-
"is_safe": is_safe,
|
|
142
|
-
"max_severity": max_sev,
|
|
143
|
-
"findings": len(findings),
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
# ── 批量扫描 ──────────────────────────────────────────────────────────────────
|
|
148
|
-
def scan_batch(directory: str, args) -> list[dict]:
|
|
149
|
-
base = Path(directory).expanduser().resolve()
|
|
150
|
-
|
|
151
|
-
if not base.exists() or not base.is_dir():
|
|
152
|
-
print(RED(f"❌ 目录不存在: {base}"))
|
|
153
|
-
return []
|
|
154
|
-
|
|
155
|
-
# 查找所有含 SKILL.md 的子目录
|
|
156
|
-
skills: list[Path] = []
|
|
157
|
-
|
|
158
|
-
if getattr(args, "recursive", False):
|
|
159
|
-
for skill_md in sorted(base.rglob("SKILL.md")):
|
|
160
|
-
skills.append(skill_md.parent)
|
|
161
|
-
else:
|
|
162
|
-
for entry in sorted(base.iterdir()):
|
|
163
|
-
if entry.is_dir() and (entry / "SKILL.md").exists():
|
|
164
|
-
skills.append(entry)
|
|
165
|
-
|
|
166
|
-
if not skills:
|
|
167
|
-
print(YELLOW(f"⚠️ 在 {base} 中未找到任何 Skill(含 SKILL.md 的目录)"))
|
|
168
|
-
return []
|
|
169
|
-
|
|
170
|
-
print(f"\n{BOLD('📂 批量扫描目录:')} {CYAN(str(base))}")
|
|
171
|
-
print(f" 发现 {BOLD(str(len(skills)))} 个 Skill\n")
|
|
172
|
-
print("─" * 60)
|
|
173
|
-
|
|
174
|
-
results = []
|
|
175
|
-
safe_count = 0
|
|
176
|
-
unsafe_count = 0
|
|
177
|
-
error_count = 0
|
|
178
|
-
|
|
179
|
-
for skill_path in skills:
|
|
180
|
-
r = scan_single(str(skill_path), args)
|
|
181
|
-
results.append(r)
|
|
182
|
-
if r.get("error"):
|
|
183
|
-
error_count += 1
|
|
184
|
-
elif r.get("is_safe"):
|
|
185
|
-
safe_count += 1
|
|
186
|
-
else:
|
|
187
|
-
unsafe_count += 1
|
|
188
|
-
|
|
189
|
-
# 汇总
|
|
190
|
-
print("\n" + "═" * 60)
|
|
191
|
-
print(BOLD("📊 批量扫描汇总"))
|
|
192
|
-
print("═" * 60)
|
|
193
|
-
print(f" 总计: {len(results)} 个 Skill")
|
|
194
|
-
print(f" {GREEN('✅ 安全:')} {safe_count} 个")
|
|
195
|
-
print(f" {RED('❌ 问题:')} {unsafe_count} 个")
|
|
196
|
-
if error_count:
|
|
197
|
-
print(f" {YELLOW('⚠️ 错误:')} {error_count} 个")
|
|
198
|
-
|
|
199
|
-
# 问题 Skill 列表
|
|
200
|
-
unsafe = [r for r in results if not r.get("is_safe") and not r.get("error")]
|
|
201
|
-
if unsafe:
|
|
202
|
-
print(f"\n {BOLD(RED('需要关注的 Skills:'))}")
|
|
203
|
-
for r in unsafe:
|
|
204
|
-
sev = r.get("max_severity", "UNKNOWN")
|
|
205
|
-
print(f" {severity_label(sev)} {r['name']} ({r.get('findings', 0)} 条发现)")
|
|
206
|
-
print(f" {DIM(r['path'])}")
|
|
207
|
-
|
|
208
|
-
return results
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# ── JSON 输出 ─────────────────────────────────────────────────────────────────
|
|
212
|
-
def save_json(data, output_path: str):
|
|
213
|
-
with open(output_path, "w", encoding="utf-8") as f:
|
|
214
|
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
215
|
-
print(f"\n{GREEN('💾 结果已保存:')} {output_path}")
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
# ── CLI 入口 ──────────────────────────────────────────────────────────────────
|
|
313
|
+
# ── 命令行接口 ────────────────────────────────────────────────────────────────
|
|
219
314
|
def main():
|
|
220
315
|
parser = argparse.ArgumentParser(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
224
|
-
epilog="""
|
|
225
|
-
示例:
|
|
226
|
-
单个扫描:
|
|
227
|
-
python scan.py scan ~/.openclaw/skills/my-skill
|
|
228
|
-
python scan.py scan ./my-skill --detailed --behavioral
|
|
229
|
-
|
|
230
|
-
批量扫描:
|
|
231
|
-
python scan.py batch ~/.openclaw/skills
|
|
232
|
-
python scan.py batch ~/.openclaw/skills --recursive --json results.json
|
|
233
|
-
python scan.py batch ./skills --detailed --llm
|
|
234
|
-
""",
|
|
316
|
+
description="OpenClaw Skills Security Scanner (HTTP Client)",
|
|
317
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
235
318
|
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
319
|
+
|
|
320
|
+
parser.add_argument(
|
|
321
|
+
'--api-url',
|
|
322
|
+
default=DEFAULT_API_URL,
|
|
323
|
+
help=f'API 服务地址 (默认: {DEFAULT_API_URL})'
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
subparsers = parser.add_subparsers(dest='command', help='命令')
|
|
327
|
+
|
|
328
|
+
# scan 命令
|
|
329
|
+
scan_parser = subparsers.add_parser('scan', help='扫描单个 Skill(上传 ZIP)')
|
|
330
|
+
scan_parser.add_argument('path', help='Skill 目录路径')
|
|
331
|
+
scan_parser.add_argument('--detailed', action='store_true', help='显示详细发现')
|
|
332
|
+
scan_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
333
|
+
scan_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
334
|
+
scan_parser.add_argument('--virustotal', action='store_true', help='启用 VirusTotal')
|
|
335
|
+
scan_parser.add_argument('--aidefense', action='store_true', help='启用 AI Defense')
|
|
336
|
+
scan_parser.add_argument('--trigger', action='store_true', help='启用触发器分析')
|
|
337
|
+
scan_parser.add_argument('--meta', action='store_true', help='启用元分析(误报过滤)')
|
|
338
|
+
scan_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
339
|
+
scan_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
340
|
+
|
|
341
|
+
# batch 命令(服务器本地路径批量扫描)
|
|
342
|
+
batch_parser = subparsers.add_parser('batch', help='批量扫描(服务器本地目录)')
|
|
343
|
+
batch_parser.add_argument('path', help='Skills 目录路径(服务器本地)')
|
|
344
|
+
batch_parser.add_argument('--recursive', action='store_true', help='递归扫描')
|
|
345
|
+
batch_parser.add_argument('--check-overlap', action='store_true', help='检查技能描述重叠')
|
|
346
|
+
batch_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
347
|
+
batch_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
348
|
+
|
|
349
|
+
# batch-upload 命令(客户端批量上传)
|
|
350
|
+
batch_upload_parser = subparsers.add_parser('batch-upload', help='批量上传多个 Skills(客户端循环)')
|
|
351
|
+
batch_upload_parser.add_argument('paths', nargs='+', help='多个 Skill 目录路径')
|
|
352
|
+
batch_upload_parser.add_argument('--behavioral', action='store_true', help='启用行为分析')
|
|
353
|
+
batch_upload_parser.add_argument('--llm', action='store_true', help='启用 LLM 分析')
|
|
354
|
+
batch_upload_parser.add_argument('--policy', default='balanced', help='扫描策略')
|
|
355
|
+
batch_upload_parser.add_argument('--json', metavar='FILE', help='输出 JSON 到文件')
|
|
356
|
+
|
|
357
|
+
# health 命令
|
|
358
|
+
subparsers.add_parser('health', help='健康检查')
|
|
359
|
+
|
|
256
360
|
args = parser.parse_args()
|
|
257
|
-
|
|
258
|
-
if args.command
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
361
|
+
|
|
362
|
+
if not args.command:
|
|
363
|
+
parser.print_help()
|
|
364
|
+
sys.exit(1)
|
|
365
|
+
|
|
366
|
+
# 创建客户端
|
|
367
|
+
client = SkillScannerClient(args.api_url)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
if args.command == 'health':
|
|
371
|
+
if client.health_check():
|
|
372
|
+
print(GREEN("✓") + " API 服务正常")
|
|
373
|
+
sys.exit(0)
|
|
374
|
+
else:
|
|
375
|
+
print(RED("✗") + f" API 服务不可用: {args.api_url}")
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
|
|
378
|
+
elif args.command == 'scan':
|
|
379
|
+
print(f"正在扫描: {args.path}")
|
|
380
|
+
result = client.scan_upload(
|
|
381
|
+
args.path,
|
|
382
|
+
policy=args.policy,
|
|
383
|
+
use_llm=args.llm,
|
|
384
|
+
use_behavioral=args.behavioral,
|
|
385
|
+
use_virustotal=args.virustotal if hasattr(args, 'virustotal') else False,
|
|
386
|
+
use_aidefense=args.aidefense if hasattr(args, 'aidefense') else False,
|
|
387
|
+
use_trigger=args.trigger if hasattr(args, 'trigger') else False,
|
|
388
|
+
enable_meta=args.meta if hasattr(args, 'meta') else False
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# 输出结果
|
|
392
|
+
if args.json:
|
|
393
|
+
with open(args.json, 'w') as f:
|
|
394
|
+
json.dump(result, f, indent=2)
|
|
395
|
+
print(f"结果已保存到: {args.json}")
|
|
396
|
+
else:
|
|
397
|
+
print(format_scan_result(result, args.detailed))
|
|
398
|
+
|
|
399
|
+
# 退出码
|
|
400
|
+
sys.exit(0 if result.get('is_safe', False) else 1)
|
|
401
|
+
|
|
402
|
+
elif args.command == 'batch':
|
|
403
|
+
print(f"正在批量扫描: {args.path}")
|
|
404
|
+
scan_id = client.scan_batch(
|
|
405
|
+
args.path,
|
|
406
|
+
recursive=args.recursive,
|
|
407
|
+
check_overlap=args.check_overlap if hasattr(args, 'check_overlap') else False,
|
|
408
|
+
policy=args.policy
|
|
409
|
+
)
|
|
410
|
+
print(f"扫描 ID: {scan_id}")
|
|
411
|
+
print("等待扫描完成...")
|
|
412
|
+
|
|
413
|
+
result = client.wait_for_batch(scan_id)
|
|
414
|
+
|
|
415
|
+
# 输出结果
|
|
416
|
+
if args.json:
|
|
417
|
+
with open(args.json, 'w') as f:
|
|
418
|
+
json.dump(result, f, indent=2)
|
|
419
|
+
print(f"结果已保存到: {args.json}")
|
|
420
|
+
else:
|
|
421
|
+
print(format_batch_result(result))
|
|
422
|
+
|
|
423
|
+
# 退出码
|
|
424
|
+
unsafe = result.get('unsafe_skills', 0)
|
|
425
|
+
sys.exit(0 if unsafe == 0 else 1)
|
|
426
|
+
|
|
427
|
+
elif args.command == 'batch-upload':
|
|
428
|
+
print(f"正在批量上传 {len(args.paths)} 个 Skills...")
|
|
429
|
+
results = client.scan_batch_upload(
|
|
430
|
+
args.paths,
|
|
431
|
+
policy=args.policy,
|
|
432
|
+
use_llm=args.llm,
|
|
433
|
+
use_behavioral=args.behavioral
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# 统计结果
|
|
437
|
+
total = len(results)
|
|
438
|
+
success = sum(1 for r in results if r['success'])
|
|
439
|
+
failed = total - success
|
|
440
|
+
|
|
441
|
+
# 输出结果
|
|
442
|
+
if args.json:
|
|
443
|
+
with open(args.json, 'w') as f:
|
|
444
|
+
json.dump(results, f, indent=2)
|
|
445
|
+
print(f"\n结果已保存到: {args.json}")
|
|
446
|
+
|
|
447
|
+
print(f"\n批量上传完成: {success}/{total} 成功, {failed} 失败")
|
|
448
|
+
|
|
449
|
+
# 退出码
|
|
450
|
+
sys.exit(0 if failed == 0 else 1)
|
|
451
|
+
|
|
452
|
+
except requests.exceptions.ConnectionError:
|
|
453
|
+
print(RED("✗") + f" 无法连接到 API 服务: {args.api_url}")
|
|
454
|
+
print("请确保 skill-scanner-api 服务正在运行")
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
except requests.exceptions.Timeout:
|
|
457
|
+
print(RED("✗") + " 请求超时")
|
|
458
|
+
sys.exit(1)
|
|
459
|
+
except requests.exceptions.HTTPError as e:
|
|
460
|
+
print(RED("✗") + f" HTTP 错误: {e}")
|
|
461
|
+
if e.response is not None:
|
|
462
|
+
try:
|
|
463
|
+
error_detail = e.response.json()
|
|
464
|
+
print(f"详情: {error_detail}")
|
|
465
|
+
except:
|
|
466
|
+
print(f"响应: {e.response.text}")
|
|
467
|
+
sys.exit(1)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
print(RED("✗") + f" 错误: {e}")
|
|
470
|
+
import traceback
|
|
471
|
+
traceback.print_exc()
|
|
472
|
+
sys.exit(1)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == '__main__':
|
|
274
476
|
main()
|