@pwddd/skills-scanner 1.2.4 → 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.

Potentially problematic release.


This version of @pwddd/skills-scanner might be problematic. Click here for more details.

package/README.md CHANGED
@@ -1,12 +1,26 @@
1
1
  # openclaw-skills-scanner
2
2
 
3
- OpenClaw Plugin,基于 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) 实现对 OpenClaw Skills 的安全扫描。
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 依赖 | `registerService` | ✅ 全自动 |
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 @yourscope/openclaw-skills-scanner
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 并安装 `cisco-ai-skill-scanner`
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
- # 修改 package.json 里的 name 为你的 scope
258
+ # 已发布为 @pwddd/skills-scanner
136
259
  npm login
137
260
  npm publish --access public
138
261
  ```
package/index.ts CHANGED
@@ -42,10 +42,16 @@ const QUARANTINE_DIR = join(STATE_DIR, "quarantine");
42
42
  // ── 型別 ──────────────────────────────────────────────────────────────────────
43
43
 
44
44
  interface ScannerConfig {
45
+ /** API 服务地址 */
46
+ apiUrl?: string;
45
47
  /** 扫描目录列表,默认自动检测 */
46
48
  scanDirs?: string[];
47
49
  /** 是否启用行为分析(较慢但更准确) */
48
50
  behavioral?: boolean;
51
+ /** 是否启用 LLM 分析 */
52
+ useLLM?: boolean;
53
+ /** 扫描策略 */
54
+ policy?: "strict" | "balanced" | "permissive";
49
55
  /**
50
56
  * 安装前扫描(fs.watch):
51
57
  * - "on" 监听所有 scanDirs(默认)
@@ -75,10 +81,10 @@ function hasUv(): boolean {
75
81
 
76
82
  function isVenvReady(): boolean {
77
83
  if (!existsSync(VENV_PYTHON)) return false;
78
-
79
- // 检查 cisco-ai-skill-scanner 是否真的安装
84
+
85
+ // 检查 requests 是否安装
80
86
  try {
81
- execSync(`"${VENV_PYTHON}" -c "import skill_scanner"`, { stdio: 'ignore' });
87
+ execSync(`"${VENV_PYTHON}" -c "import requests"`, { stdio: 'ignore' });
82
88
  return true;
83
89
  } catch {
84
90
  return false;
@@ -115,74 +121,40 @@ function saveState(s: ScanState) {
115
121
 
116
122
  async function ensureDeps(logger: any): Promise<boolean> {
117
123
  if (isVenvReady()) {
118
- logger.info("[skills-scanner] Python 依赖已就绪(cisco-ai-skill-scanner 已安装)");
124
+ logger.info("[skills-scanner] Python 依赖已就绪(requests 已安装)");
119
125
  return true;
120
126
  }
121
-
127
+
122
128
  if (!hasUv()) {
123
129
  logger.warn("[skills-scanner] uv 未安装:brew install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh");
124
130
  return false;
125
131
  }
126
-
127
- logger.info("[skills-scanner] 正在安装 cisco-ai-skill-scanner...");
128
-
132
+
133
+ logger.info("[skills-scanner] 正在安装 Python 依赖...");
134
+
129
135
  try {
130
- // 使用 uv 创建 venv 并安装包(一步完成)
131
- logger.info("[skills-scanner] 创建虚拟环境并安装依赖...");
132
136
  const venvDir = join(SKILL_DIR, ".venv");
133
-
137
+
134
138
  // 如果 venv 已存在,先删除
135
139
  if (existsSync(venvDir)) {
136
140
  logger.info("[skills-scanner] 清理旧的虚拟环境...");
137
141
  rmSync(venvDir, { recursive: true, force: true });
138
142
  }
139
-
140
- // 使用 uv 创建 venv
143
+
144
+ // 创建 venv
141
145
  await execAsync(`uv venv "${venvDir}" --python 3.10`);
142
146
  logger.info("[skills-scanner] 虚拟环境创建完成");
143
-
144
- // 使用 uv pip 安装包(这是关键!)
145
- logger.info("[skills-scanner] 安装 cisco-ai-skill-scanner...");
146
- const installCmd = `uv pip install --python "${VENV_PYTHON}" cisco-ai-skill-scanner`;
147
- logger.debug(`[skills-scanner] 执行命令: ${installCmd}`);
148
-
149
- const installResult = await execAsync(installCmd);
150
- logger.info(`[skills-scanner] 安装输出: ${installResult.stdout.trim()}`);
151
- if (installResult.stderr) {
152
- logger.warn(`[skills-scanner] 安装警告: ${installResult.stderr.trim()}`);
153
- }
154
-
147
+
148
+ // 安装 requests
149
+ logger.info("[skills-scanner] 安装 requests...");
150
+ await execAsync(`uv pip install --python "${VENV_PYTHON}" requests>=2.31.0`);
151
+
155
152
  // 验证安装
156
- logger.info("[skills-scanner] 验证安装...");
157
- try {
158
- const verifyResult = execSync(`"${VENV_PYTHON}" -c "import skill_scanner; print('Version:', skill_scanner.__version__)"`, { encoding: 'utf-8' });
159
- logger.info(`[skills-scanner] ✅ 验证成功: ${verifyResult.trim()}`);
160
- return true;
161
- } catch (verifyErr: any) {
162
- logger.error("[skills-scanner] ❌ 验证失败");
163
- logger.error(`[skills-scanner] 错误: ${verifyErr.message}`);
164
-
165
- // 尝试手动检查文件
166
- try {
167
- const sitePackages = join(venvDir, "lib");
168
- logger.debug(`[skills-scanner] 检查 site-packages: ${sitePackages}`);
169
- if (existsSync(sitePackages)) {
170
- const { stdout } = await execAsync(`find "${sitePackages}" -name "skill_scanner*" -type d`);
171
- logger.debug(`[skills-scanner] 找到的包: ${stdout.trim() || "无"}`);
172
- }
173
- } catch {}
174
-
175
- return false;
176
- }
153
+ execSync(`"${VENV_PYTHON}" -c "import requests"`, { stdio: 'ignore' });
154
+ logger.info("[skills-scanner] ✅ 依赖安装完成");
155
+ return true;
177
156
  } catch (err: any) {
178
157
  logger.error(`[skills-scanner] ⚠️ 依赖安装失败: ${err.message}`);
179
- if (err.stdout) logger.error(`[skills-scanner] stdout: ${err.stdout}`);
180
- if (err.stderr) logger.error(`[skills-scanner] stderr: ${err.stderr}`);
181
- logger.error(`[skills-scanner] 请手动运行:`);
182
- logger.error(`[skills-scanner] cd ~/.openclaw/extensions/skills-scanner/skills/skills-scanner`);
183
- logger.error(`[skills-scanner] rm -rf .venv`);
184
- logger.error(`[skills-scanner] uv venv .venv --python 3.10`);
185
- logger.error(`[skills-scanner] uv pip install --python .venv/bin/python cisco-ai-skill-scanner`);
186
158
  return false;
187
159
  }
188
160
  }
@@ -190,25 +162,24 @@ async function ensureDeps(logger: any): Promise<boolean> {
190
162
  async function runScan(
191
163
  mode: "scan" | "batch",
192
164
  target: string,
193
- 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 } = {}
194
166
  ): Promise<{ exitCode: number; output: string }> {
195
167
  const args = [mode, target];
196
168
  if (opts.detailed) args.push("--detailed");
197
169
  if (opts.behavioral) args.push("--behavioral");
198
170
  if (opts.recursive) args.push("--recursive");
171
+ if (opts.useLLM) args.push("--llm");
172
+ if (opts.policy) args.push("--policy", opts.policy);
199
173
  if (opts.jsonOut) args.push("--json", opts.jsonOut);
174
+ if (opts.apiUrl) args.unshift("--api-url", opts.apiUrl);
200
175
 
201
176
  const cmd = `"${VENV_PYTHON}" "${SCAN_SCRIPT}" ${args.map(a => `"${a}"`).join(" ")}`;
202
-
177
+
203
178
  // 调试日志
204
179
  console.log(`[skills-scanner] 执行命令: ${cmd}`);
205
- console.log(`[skills-scanner] VENV_PYTHON: ${VENV_PYTHON}`);
206
- console.log(`[skills-scanner] SCAN_SCRIPT: ${SCAN_SCRIPT}`);
207
- console.log(`[skills-scanner] Python 存在: ${existsSync(VENV_PYTHON)}`);
208
- console.log(`[skills-scanner] Script 存在: ${existsSync(SCAN_SCRIPT)}`);
209
-
180
+
210
181
  try {
211
- // 清除代理环境变量,避免 LiteLLM 加载时出错
182
+ // 清除代理环境变量,避免连接问题
212
183
  const env = { ...process.env };
213
184
  delete env.http_proxy;
214
185
  delete env.https_proxy;
@@ -216,10 +187,10 @@ async function runScan(
216
187
  delete env.HTTPS_PROXY;
217
188
  delete env.all_proxy;
218
189
  delete env.ALL_PROXY;
219
-
220
- const { stdout, stderr } = await execAsync(cmd, {
190
+
191
+ const { stdout, stderr } = await execAsync(cmd, {
221
192
  timeout: 180_000,
222
- env
193
+ env
223
194
  });
224
195
  return { exitCode: 0, output: (stdout + stderr).trim() };
225
196
  } catch (err: any) {
@@ -230,7 +201,14 @@ async function runScan(
230
201
 
231
202
  // ── 日报 ──────────────────────────────────────────────────────────────────────
232
203
 
233
- async function buildDailyReport(dirs: string[], behavioral: boolean, logger: any): Promise<string> {
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> {
234
212
  const now = new Date();
235
213
  const dateStr = now.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
236
214
  const timeStr = now.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
@@ -245,7 +223,14 @@ async function buildDailyReport(dirs: string[], behavioral: boolean, logger: any
245
223
  const expanded = expandPath(dir);
246
224
  if (!existsSync(expanded)) continue;
247
225
  const tmpJson = join(STATE_DIR, `tmp-${Date.now()}.json`);
248
- await runScan("batch", expanded, { behavioral, recursive: true, jsonOut: tmpJson });
226
+ await runScan("batch", expanded, {
227
+ behavioral,
228
+ recursive: true,
229
+ jsonOut: tmpJson,
230
+ apiUrl,
231
+ useLLM,
232
+ policy
233
+ });
249
234
  try {
250
235
  const rows: any[] = JSON.parse(readFileSync(tmpJson, "utf-8"));
251
236
  try { rmSync(tmpJson); } catch {}
@@ -294,6 +279,9 @@ async function handleNewSkill(
294
279
  skillPath: string,
295
280
  onUnsafe: "quarantine" | "delete" | "warn",
296
281
  behavioral: boolean,
282
+ apiUrl: string,
283
+ useLLM: boolean,
284
+ policy: string,
297
285
  notifyFn: (msg: string) => void,
298
286
  logger: any
299
287
  ) {
@@ -302,7 +290,13 @@ async function handleNewSkill(
302
290
  logger.info(`[skills-scanner] 🔍 检测到新 Skill,开始安装前扫描: ${name}`);
303
291
  notifyFn(`🔍 检测到新 Skill \`${name}\`,正在安全扫描...`);
304
292
 
305
- const res = await runScan("scan", skillPath, { behavioral, detailed: true });
293
+ const res = await runScan("scan", skillPath, {
294
+ behavioral,
295
+ detailed: true,
296
+ apiUrl,
297
+ useLLM,
298
+ policy
299
+ });
306
300
 
307
301
  if (res.exitCode === 0) {
308
302
  notifyFn(`✅ \`${name}\` 安全检查通过,可以正常使用。`);
@@ -340,6 +334,9 @@ function startWatcher(
340
334
  dirs: string[],
341
335
  onUnsafe: "quarantine" | "delete" | "warn",
342
336
  behavioral: boolean,
337
+ apiUrl: string,
338
+ useLLM: boolean,
339
+ policy: string,
343
340
  notifyFn: (msg: string) => void,
344
341
  logger: any
345
342
  ): () => void {
@@ -360,7 +357,7 @@ function startWatcher(
360
357
  if (prev) clearTimeout(prev);
361
358
  timers.set(skillPath, setTimeout(() => {
362
359
  timers.delete(skillPath);
363
- handleNewSkill(skillPath, onUnsafe, behavioral, notifyFn, logger);
360
+ handleNewSkill(skillPath, onUnsafe, behavioral, apiUrl, useLLM, policy, notifyFn, logger);
364
361
  }, 500));
365
362
  });
366
363
  });
@@ -377,15 +374,19 @@ function startWatcher(
377
374
 
378
375
  export default function register(api: any) {
379
376
  const cfg: ScannerConfig = api.config?.plugins?.entries?.["skills-scanner"]?.config ?? {};
377
+ const apiUrl = cfg.apiUrl ?? "http://localhost:8000";
380
378
  const scanDirs = (cfg.scanDirs?.map(expandPath) ?? []).filter(existsSync).length > 0
381
379
  ? (cfg.scanDirs!.map(expandPath))
382
380
  : defaultScanDirs();
383
381
  const behavioral = cfg.behavioral ?? false;
382
+ const useLLM = cfg.useLLM ?? false;
383
+ const policy = cfg.policy ?? "balanced";
384
384
  const preInstallScan = cfg.preInstallScan ?? "on";
385
385
  const onUnsafe = cfg.onUnsafe ?? "quarantine";
386
386
 
387
387
  api.logger.info("[skills-scanner] ═══════════════════════════════════════");
388
388
  api.logger.info("[skills-scanner] Plugin 正在加载...");
389
+ api.logger.info(`[skills-scanner] API 服务地址: ${apiUrl}`);
389
390
  api.logger.info(`[skills-scanner] PLUGIN_ROOT: ${PLUGIN_ROOT}`);
390
391
  api.logger.info(`[skills-scanner] SKILL_DIR: ${SKILL_DIR}`);
391
392
  api.logger.info(`[skills-scanner] VENV_PYTHON: ${VENV_PYTHON}`);
@@ -434,7 +435,7 @@ export default function register(api: any) {
434
435
 
435
436
  if (preInstallScan === "on" && scanDirs.length > 0) {
436
437
  api.logger.info(`[skills-scanner] 📁 启动文件监控: ${scanDirs.length} 个目录`);
437
- stopWatcher = startWatcher(scanDirs, onUnsafe, behavioral, persistWatcherAlert, api.logger);
438
+ stopWatcher = startWatcher(scanDirs, onUnsafe, behavioral, apiUrl, useLLM, policy, persistWatcherAlert, api.logger);
438
439
  api.logger.info("[skills-scanner] ✅ 文件监控已启动");
439
440
  } else {
440
441
  api.logger.info("[skills-scanner] ⏭️ 安装前扫描已禁用");
@@ -493,20 +494,19 @@ export default function register(api: any) {
493
494
  if (!isVenvReady()) {
494
495
  return { text: "⏳ Python 依赖尚未就绪,请稍后重试或查看日志" };
495
496
  }
496
-
497
- // 测试 Python 能否导入
498
- try {
499
- execSync(`"${VENV_PYTHON}" -c "import skill_scanner; print('Import OK')"`, { encoding: 'utf-8' });
500
- } catch (err: any) {
501
- return { text: `❌ Python 导入测试失败:\n\`\`\`\n${err.message}\n\`\`\`` };
502
- }
503
497
 
504
498
  const parts = raw.split(/\s+/);
505
499
  const skillPath = expandPath(parts.find(p => !p.startsWith("--")) ?? "");
506
500
  const detailed = parts.includes("--detailed");
507
501
  const useBehav = parts.includes("--behavioral") || behavioral;
508
502
 
509
- const res = await runScan("scan", skillPath, { detailed, behavioral: useBehav });
503
+ const res = await runScan("scan", skillPath, {
504
+ detailed,
505
+ behavioral: useBehav,
506
+ apiUrl,
507
+ useLLM,
508
+ policy
509
+ });
510
510
  const icon = res.exitCode === 0 ? "✅" : "❌";
511
511
  return { text: `${icon} 扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
512
512
  },
@@ -529,7 +529,14 @@ export default function register(api: any) {
529
529
  const detailed = parts.includes("--detailed");
530
530
  const useBehav = parts.includes("--behavioral") || behavioral;
531
531
 
532
- const res = await runScan("batch", dirPath, { recursive, detailed, behavioral: useBehav });
532
+ const res = await runScan("batch", dirPath, {
533
+ recursive,
534
+ detailed,
535
+ behavioral: useBehav,
536
+ apiUrl,
537
+ useLLM,
538
+ policy
539
+ });
533
540
  const icon = res.exitCode === 0 ? "✅" : "❌";
534
541
  return { text: `${icon} 批量扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
535
542
  },
@@ -544,7 +551,7 @@ export default function register(api: any) {
544
551
  handler: async (_ctx: any) => {
545
552
  if (!isVenvReady()) return { text: "⏳ Python 依赖尚未就绪,请稍后重试" };
546
553
  if (scanDirs.length === 0) return { text: "⚠️ 未找到可扫描目录,请检查配置" };
547
- const report = await buildDailyReport(scanDirs, behavioral, api.logger);
554
+ const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, api.logger);
548
555
  return { text: report };
549
556
  },
550
557
  });
@@ -612,14 +619,21 @@ export default function register(api: any) {
612
619
  const { path: p, mode = "scan", recursive = false, detailed = false } = params ?? {};
613
620
  if (!p) return respond(false, { error: "缺少 path 参数" });
614
621
  if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
615
- const res = await runScan(mode === "batch" ? "batch" : "scan", expandPath(p), { recursive, detailed, behavioral });
622
+ const res = await runScan(mode === "batch" ? "batch" : "scan", expandPath(p), {
623
+ recursive,
624
+ detailed,
625
+ behavioral,
626
+ apiUrl,
627
+ useLLM,
628
+ policy
629
+ });
616
630
  respond(res.exitCode === 0, { output: res.output, exitCode: res.exitCode, is_safe: res.exitCode === 0 });
617
631
  });
618
632
 
619
633
  api.registerGatewayMethod("skillsScanner.report", async ({ respond }: any) => {
620
634
  if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
621
635
  if (scanDirs.length === 0) return respond(false, { error: "未找到可扫描目录" });
622
- const report = await buildDailyReport(scanDirs, behavioral, api.logger);
636
+ const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, api.logger);
623
637
  respond(true, { report, state: loadState() });
624
638
  });
625
639
 
@@ -632,7 +646,12 @@ export default function register(api: any) {
632
646
  .option("--detailed", "显示所有 findings")
633
647
  .option("--behavioral", "启用行为分析")
634
648
  .action(async (p: string, opts: any) => {
635
- const res = await runScan("scan", expandPath(p), opts);
649
+ const res = await runScan("scan", expandPath(p), {
650
+ ...opts,
651
+ apiUrl,
652
+ useLLM,
653
+ policy
654
+ });
636
655
  console.log(res.output);
637
656
  process.exit(res.exitCode);
638
657
  });
@@ -643,7 +662,12 @@ export default function register(api: any) {
643
662
  .option("--detailed", "显示所有 findings")
644
663
  .option("--behavioral", "启用行为分析")
645
664
  .action(async (d: string, opts: any) => {
646
- const res = await runScan("batch", expandPath(d), opts);
665
+ const res = await runScan("batch", expandPath(d), {
666
+ ...opts,
667
+ apiUrl,
668
+ useLLM,
669
+ policy
670
+ });
647
671
  console.log(res.output);
648
672
  process.exit(res.exitCode);
649
673
  });
@@ -651,7 +675,7 @@ export default function register(api: any) {
651
675
  cmd.command("report")
652
676
  .description("立即执行全量扫描并打印日报")
653
677
  .action(async () => {
654
- const report = await buildDailyReport(scanDirs, behavioral, console);
678
+ const report = await buildDailyReport(scanDirs, behavioral, apiUrl, useLLM, policy, console);
655
679
  console.log(report);
656
680
  });
657
681
  }, { commands: ["skills-scan"] });
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "id": "skills-scanner",
3
3
  "name": "Skills Security Scanner",
4
- "description": "Skills 安全扫描:安装前扫描(fs.watch)、按需扫描、安全日报",
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": "启用 AST 行为分析(更准确但较慢)"
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pwddd/skills-scanner",
3
- "version": "1.2.4",
3
+ "version": "2.0.0",
4
4
  "description": "OpenClaw Plugin:Skills 安全扫描、安装前拦截、安全日报",
5
5
  "main": "index.ts",
6
6
  "files": [
@@ -1,11 +1,11 @@
1
1
  # /// script
2
2
  # dependencies = [
3
- # "cisco-ai-skill-scanner>=0.1.0",
3
+ # "requests>=2.31.0",
4
4
  # ]
5
5
  # ///
6
6
  """
7
- OpenClaw Skills Security Scanner
8
- 基于 cisco-ai-skill-scanner,支持单个/批量扫描
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
- from skill_scanner import SkillScanner
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("❌ cisco-ai-skill-scanner 未安装。")
27
+ print("❌ requests 未安装。")
29
28
  print(f" 导入错误: {e}")
30
- print(" 请运行: uv pip install cisco-ai-skill-scanner")
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,428 @@ BOLD = lambda t: c(t, "1")
58
49
  DIM = lambda t: c(t, "2")
59
50
 
60
51
 
61
- # ── 严重级别 ──────────────────────────────────────────────────────────────────
62
- SEVERITY_COLORS = {
63
- "CRITICAL": RED,
64
- "HIGH": RED,
65
- "MEDIUM": YELLOW,
66
- "LOW": GREEN,
67
- "INFO": CYAN,
68
- }
69
-
70
- def severity_label(sev: str) -> str:
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) -> bool:
61
+ """健康检查"""
82
62
  try:
83
- from skill_scanner.core.analyzers import LLMAnalyzer
84
- api_key = os.environ.get("SKILL_SCANNER_LLM_API_KEY")
85
- if not api_key:
86
- print(YELLOW("⚠️ 未设置 SKILL_SCANNER_LLM_API_KEY,跳过 LLM 分析器"))
87
- else:
88
- analyzers.append(LLMAnalyzer())
89
- except ImportError:
90
- print(YELLOW("⚠️ LLM 分析器不可用,请安装: pip install cisco-ai-skill-scanner[llm]"))
91
- return SkillScanner(analyzers=analyzers)
92
-
93
-
94
- # ── 单个 Skill 扫描 ───────────────────────────────────────────────────────────
95
- def scan_single(path: str, args) -> dict:
96
- skill_path = Path(path).expanduser().resolve()
97
-
98
- if not skill_path.exists():
99
- return {"path": str(skill_path), "error": "路径不存在", "is_safe": False}
100
-
101
- if not skill_path.is_dir():
102
- return {"path": str(skill_path), "error": "必须是目录(Skill 文件夹)", "is_safe": False}
103
-
104
- skill_md = skill_path / "SKILL.md"
105
- if not skill_md.exists():
106
- return {"path": str(skill_path), "error": "未找到 SKILL.md,不是合法的 Skill", "is_safe": False}
107
-
108
- print(f"\n{BOLD('🔍 扫描:')} {CYAN(str(skill_path))}")
109
-
110
- scanner = build_scanner(
111
- use_behavioral=getattr(args, "behavioral", False),
112
- use_llm=getattr(args, "llm", False),
113
- )
114
-
115
- result = scanner.scan_skill(str(skill_path))
116
- findings = result.findings if hasattr(result, "findings") else []
117
- max_sev = str(result.max_severity) if hasattr(result, "max_severity") else "UNKNOWN"
118
- is_safe = bool(result.is_safe) if hasattr(result, "is_safe") else len(findings) == 0
119
-
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
+
120
248
  # 状态行
121
- if is_safe:
122
- status = GREEN(" 安全")
123
- else:
124
- status = RED(" 发现问题") if max_sev in ("CRITICAL", "HIGH") else YELLOW("⚠️ 需关注")
125
-
126
- print(f" 状态: {status} | 最高严重级别: {severity_label(max_sev)} | 发现: {len(findings)} 条")
127
-
128
- # 详细 findings
129
- if findings and getattr(args, "detailed", False):
130
- print(f"\n {BOLD('发现详情:')}")
131
- for i, f in enumerate(findings, 1):
132
- sev = str(getattr(f, "severity", "INFO")).upper()
133
- name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
134
- desc = getattr(f, "description", getattr(f, "message", ""))
135
- loc = getattr(f, "location", getattr(f, "file", ""))
136
- print(f" {DIM(str(i)+'.')} {severity_label(sev)} {BOLD(name)}")
137
- if desc:
138
- print(f" {desc}")
139
- if loc:
140
- print(f" {DIM('位置: ' + str(loc))}")
141
- elif findings and not getattr(args, "detailed", False):
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 []
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)
168
270
 
169
- # 查找所有含 SKILL.md 的子目录
170
- skills: list[Path] = []
171
271
 
172
- if getattr(args, "recursive", False):
173
- for skill_md in sorted(base.rglob("SKILL.md")):
174
- skills.append(skill_md.parent)
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)
175
309
  else:
176
- for entry in sorted(base.iterdir()):
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} 个")
310
+ return DIM(severity_upper)
212
311
 
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
312
 
222
- return results
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 入口 ──────────────────────────────────────────────────────────────────
313
+ # ── 命令行接口 ────────────────────────────────────────────────────────────────
233
314
  def main():
234
315
  parser = argparse.ArgumentParser(
235
- prog="scan.py",
236
- description="OpenClaw Skills 安全扫描器(基于 cisco-ai-skill-scanner)",
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
- """,
316
+ description="OpenClaw Skills Security Scanner (HTTP Client)",
317
+ formatter_class=argparse.RawDescriptionHelpFormatter
249
318
  )
250
-
251
- sub = parser.add_subparsers(dest="command", required=True)
252
-
253
- # -- scan 子命令
254
- p_scan = sub.add_parser("scan", help="扫描单个 Skill 目录")
255
- p_scan.add_argument("path", help="Skill 目录路径(含 SKILL.md 的文件夹)")
256
- p_scan.add_argument("--detailed", action="store_true", help="显示所有 findings 详情")
257
- p_scan.add_argument("--behavioral", action="store_true", help="启用行为分析器(AST dataflow)")
258
- p_scan.add_argument("--llm", action="store_true", help="启用 LLM 语义分析器(需要 API Key)")
259
- p_scan.add_argument("--json", metavar="FILE", help="将结果保存为 JSON 文件")
260
-
261
- # -- batch 子命令
262
- p_batch = sub.add_parser("batch", help="批量扫描目录下所有 Skills")
263
- p_batch.add_argument("directory", help="包含多个 Skill 的目录")
264
- p_batch.add_argument("--recursive", action="store_true", help="递归扫描子目录")
265
- p_batch.add_argument("--detailed", action="store_true", help="显示所有 findings 详情")
266
- p_batch.add_argument("--behavioral", action="store_true", help="启用行为分析器")
267
- p_batch.add_argument("--llm", action="store_true", help="启用 LLM 语义分析器")
268
- p_batch.add_argument("--json", metavar="FILE", help="将结果保存为 JSON 文件")
269
-
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
+
270
360
  args = parser.parse_args()
271
-
272
- if args.command == "scan":
273
- result = scan_single(args.path, args)
274
- if args.json:
275
- save_json(result, args.json)
276
- # 退出码:不安全返回 1
277
- sys.exit(0 if result.get("is_safe") else 1)
278
-
279
- elif args.command == "batch":
280
- results = scan_batch(args.directory, args)
281
- if args.json:
282
- save_json(results, args.json)
283
- unsafe = [r for r in results if not r.get("is_safe")]
284
- sys.exit(0 if not unsafe else 1)
285
-
286
-
287
- if __name__ == "__main__":
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__':
288
476
  main()