@pwddd/skills-scanner 1.0.2
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 +138 -0
- package/index.ts +488 -0
- package/openclaw.plugin.json +40 -0
- package/package.json +20 -0
- package/skills/skills-scanner/SKILL.md +136 -0
- package/skills/skills-scanner/scan.py +273 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# openclaw-skills-scanner
|
|
2
|
+
|
|
3
|
+
OpenClaw Plugin,基于 [cisco-ai-skill-scanner](https://github.com/cisco-ai-defense/skill-scanner) 实现对 OpenClaw Skills 的安全扫描。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
| 功能 | 实现方式 | 状态 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| 启动时自动安装 Python 依赖 | `registerService` | ✅ 全自动 |
|
|
10
|
+
| `/scan-skill` `/scan-skills` 按需扫描 | `registerCommand` | ✅ 全自动 |
|
|
11
|
+
| `/scan-report` 立即日报 | `registerCommand` | ✅ 全自动 |
|
|
12
|
+
| `/scan-status` 状态查看 | `registerCommand` | ✅ 全自动 |
|
|
13
|
+
| `openclaw skills-scan` CLI | `registerCli` | ✅ 全自动 |
|
|
14
|
+
| 安装前扫描(新 Skill 出现时自动扫描) | `fs.watch` | ✅ 全自动 |
|
|
15
|
+
| Gateway 启动时在日志里打印配置提示 | Plugin Hook `gateway:startup` | ✅ 全自动 |
|
|
16
|
+
| 每日定期日报 | Cron Job | ⚠️ **需手动注册一次** |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 从本地目录安装
|
|
24
|
+
openclaw plugins install ./openclaw-skills-scanner
|
|
25
|
+
|
|
26
|
+
# 或从 npm 安装(发布后)
|
|
27
|
+
openclaw plugins install @yourscope/openclaw-skills-scanner
|
|
28
|
+
|
|
29
|
+
# 重启 Gateway(Plugin 变更必须重启)
|
|
30
|
+
openclaw gateway restart
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
重启后 Gateway 会自动:
|
|
34
|
+
1. 创建 Python venv 并安装 `cisco-ai-skill-scanner`
|
|
35
|
+
2. 开始监听 Skills 目录(安装前扫描)
|
|
36
|
+
3. 在启动日志里打印是否需要注册 Cron Job
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 注册每日日报(一次性操作)
|
|
41
|
+
|
|
42
|
+
Plugin 无法自动注册 Cron Job(OpenClaw Plugin API 不提供此能力),需要手动执行一次:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
openclaw cron add \
|
|
46
|
+
--name "skills-daily-report" \
|
|
47
|
+
--cron "0 8 * * *" \
|
|
48
|
+
--tz "Asia/Shanghai" \
|
|
49
|
+
--session isolated \
|
|
50
|
+
--message "请执行 /scan-report 并把结果发送到此渠道" \
|
|
51
|
+
--announce
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
想投递到特定渠道(如 Telegram):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
openclaw cron add \
|
|
58
|
+
--name "skills-daily-report" \
|
|
59
|
+
--cron "0 8 * * *" \
|
|
60
|
+
--tz "Asia/Shanghai" \
|
|
61
|
+
--session isolated \
|
|
62
|
+
--message "请执行 /scan-report 并把结果发送到此渠道" \
|
|
63
|
+
--announce \
|
|
64
|
+
--channel telegram \
|
|
65
|
+
--to "+8613312345678"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
验证已注册:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
openclaw cron list
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 聊天命令
|
|
77
|
+
|
|
78
|
+
| 命令 | 说明 |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `/scan-skill <路径>` | 扫描单个 Skill 目录 |
|
|
81
|
+
| `/scan-skill <路径> --detailed` | 显示完整 findings |
|
|
82
|
+
| `/scan-skill <路径> --behavioral` | 启用 AST 行为分析 |
|
|
83
|
+
| `/scan-skills <目录>` | 批量扫描目录 |
|
|
84
|
+
| `/scan-skills <目录> --recursive` | 递归扫描子目录 |
|
|
85
|
+
| `/scan-report` | 立即执行全量扫描并输出日报 |
|
|
86
|
+
| `/scan-status` | 查看状态、待查告警、Cron 注册命令 |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 安装前扫描说明
|
|
91
|
+
|
|
92
|
+
Plugin 启动后用 `fs.watch` 监听所有 Skills 目录。任何新 Skill 出现(无论通过 `clawhub install`、CLI 还是手动复制)都会触发扫描。
|
|
93
|
+
|
|
94
|
+
扫描结果通过 `persistWatcherAlert` 写入 `~/.openclaw/skills-scanner/state.json`,运行 `/scan-status` 查看并清空告警列表。
|
|
95
|
+
|
|
96
|
+
> **为什么不直接发聊天消息?**
|
|
97
|
+
> OpenClaw Plugin API 没有提供在后台任务里主动推送消息给用户的方法。`event.messages.push()` 只在 Hook handler 的同步上下文中有效,`registerCommand` 的 handler 需要用户主动触发。这是平台限制,不是实现缺陷。
|
|
98
|
+
|
|
99
|
+
处置方式通过 `onUnsafe` 配置:
|
|
100
|
+
|
|
101
|
+
| 值 | 行为 |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `"quarantine"`(默认)| 移入 `~/.openclaw/skills-scanner/quarantine/` |
|
|
104
|
+
| `"delete"` | 直接删除 |
|
|
105
|
+
| `"warn"` | 仅写告警日志,保留文件 |
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 配置
|
|
110
|
+
|
|
111
|
+
```jsonc
|
|
112
|
+
// ~/.openclaw/openclaw.json
|
|
113
|
+
{
|
|
114
|
+
"plugins": {
|
|
115
|
+
"entries": {
|
|
116
|
+
"skills-scanner": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"config": {
|
|
119
|
+
"scanDirs": ["~/.openclaw/skills"], // 留空自动检测
|
|
120
|
+
"behavioral": false, // 行为分析(较慢)
|
|
121
|
+
"preInstallScan": "on", // fs.watch 安装前扫描
|
|
122
|
+
"onUnsafe": "quarantine" // quarantine | delete | warn
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 发布到 npm
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# 修改 package.json 里的 name 为你的 scope
|
|
136
|
+
npm login
|
|
137
|
+
npm publish --access public
|
|
138
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Plugin: skills-scanner
|
|
3
|
+
*
|
|
4
|
+
* 已确认可用的功能(全部有文档依据):
|
|
5
|
+
* 1. registerService — 启动时自动安装 Python 依赖(uv venv)
|
|
6
|
+
* 2. registerCommand — 聊天命令 /scan-skill /scan-skills /scan-report /scan-status
|
|
7
|
+
* 3. registerGatewayMethod — RPC 供 Control UI 调用
|
|
8
|
+
* 4. registerCli — CLI 命令 openclaw skills-scan
|
|
9
|
+
* 5. registerPluginHooksFromDir — 捆绑 gateway:startup hook(日报提醒)
|
|
10
|
+
* 6. skills/ 目录 — 捆绑内置 Skill
|
|
11
|
+
* 7. fs.watch — 监听 Skills 目录,新 Skill 出现后立即扫描(安装前扫描)
|
|
12
|
+
*
|
|
13
|
+
* 已删除的不实功能:
|
|
14
|
+
* - api.registerPluginHook() ← 该方法不存在
|
|
15
|
+
* - api.runtime.gateway.call() ← 文档无记录
|
|
16
|
+
* - api.runtime.gateway.call("cron.add") ← 文档无记录
|
|
17
|
+
* - message:preprocessed hook 拦截 ← 该事件不存在
|
|
18
|
+
*
|
|
19
|
+
* Cron 日报:需用户安装后手动执行一次 CLI 命令注册(见 README)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execSync, exec } from "child_process";
|
|
23
|
+
import {
|
|
24
|
+
existsSync, readFileSync, writeFileSync,
|
|
25
|
+
mkdirSync, watch as fsWatch, renameSync, rmSync,
|
|
26
|
+
} from "fs";
|
|
27
|
+
import { join, basename } from "path";
|
|
28
|
+
import { homedir } from "os";
|
|
29
|
+
import { promisify } from "util";
|
|
30
|
+
|
|
31
|
+
const execAsync = promisify(exec);
|
|
32
|
+
|
|
33
|
+
const PLUGIN_ROOT = __dirname;
|
|
34
|
+
const SKILL_DIR = join(PLUGIN_ROOT, "skills", "skills-scanner");
|
|
35
|
+
const VENV_PYTHON = join(SKILL_DIR, ".venv", "bin", "python");
|
|
36
|
+
const SCAN_SCRIPT = join(SKILL_DIR, "scan.py");
|
|
37
|
+
const STATE_DIR = join(homedir(), ".openclaw", "skills-scanner");
|
|
38
|
+
const STATE_FILE = join(STATE_DIR, "state.json");
|
|
39
|
+
const QUARANTINE_DIR = join(STATE_DIR, "quarantine");
|
|
40
|
+
|
|
41
|
+
// ── 型別 ──────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface ScannerConfig {
|
|
44
|
+
/** 扫描目录列表,默认自动检测 */
|
|
45
|
+
scanDirs?: string[];
|
|
46
|
+
/** 是否启用行为分析(较慢但更准确) */
|
|
47
|
+
behavioral?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* 安装前扫描(fs.watch):
|
|
50
|
+
* - "on" 监听所有 scanDirs(默认)
|
|
51
|
+
* - "off" 禁用
|
|
52
|
+
*/
|
|
53
|
+
preInstallScan?: "on" | "off";
|
|
54
|
+
/**
|
|
55
|
+
* 发现不安全 Skill 时的处理方式:
|
|
56
|
+
* - "quarantine" 移入隔离目录(默认)
|
|
57
|
+
* - "delete" 直接删除
|
|
58
|
+
* - "warn" 仅警告,保留文件
|
|
59
|
+
*/
|
|
60
|
+
onUnsafe?: "quarantine" | "delete" | "warn";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ScanState {
|
|
64
|
+
lastScanAt?: string;
|
|
65
|
+
lastUnsafeSkills?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function hasUv(): boolean {
|
|
71
|
+
try { execSync("uv --version", { stdio: "ignore" }); return true; }
|
|
72
|
+
catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isVenvReady(): boolean {
|
|
76
|
+
return existsSync(VENV_PYTHON);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function expandPath(p: string): string {
|
|
80
|
+
return p.replace(/^~/, homedir());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultScanDirs(): string[] {
|
|
84
|
+
return [
|
|
85
|
+
join(homedir(), ".openclaw", "skills"),
|
|
86
|
+
join(homedir(), ".openclaw", "workspace", "skills"),
|
|
87
|
+
].filter(existsSync);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadState(): ScanState {
|
|
91
|
+
try { return JSON.parse(readFileSync(STATE_FILE, "utf-8")); }
|
|
92
|
+
catch { return {}; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveState(s: ScanState) {
|
|
96
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
97
|
+
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensureDeps(logger: any): Promise<boolean> {
|
|
101
|
+
if (isVenvReady()) {
|
|
102
|
+
logger.info("[skills-scanner] Python 依赖已就绪");
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!hasUv()) {
|
|
106
|
+
logger.warn("[skills-scanner] uv 未安装:brew install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh");
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
logger.info("[skills-scanner] 首次启动,正在安装 cisco-ai-skill-scanner...");
|
|
110
|
+
try {
|
|
111
|
+
await execAsync(`uv venv "${join(SKILL_DIR, ".venv")}" --python 3.10 --quiet`);
|
|
112
|
+
await execAsync(`uv pip install --python "${VENV_PYTHON}" cisco-ai-skill-scanner --quiet`);
|
|
113
|
+
logger.info("[skills-scanner] ✅ 依赖安装完成");
|
|
114
|
+
return true;
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
logger.warn(`[skills-scanner] ⚠️ 依赖安装失败: ${err.message}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runScan(
|
|
122
|
+
mode: "scan" | "batch",
|
|
123
|
+
target: string,
|
|
124
|
+
opts: { detailed?: boolean; behavioral?: boolean; recursive?: boolean; jsonOut?: string } = {}
|
|
125
|
+
): Promise<{ exitCode: number; output: string }> {
|
|
126
|
+
const args = [mode, target];
|
|
127
|
+
if (opts.detailed) args.push("--detailed");
|
|
128
|
+
if (opts.behavioral) args.push("--behavioral");
|
|
129
|
+
if (opts.recursive) args.push("--recursive");
|
|
130
|
+
if (opts.jsonOut) args.push("--json", opts.jsonOut);
|
|
131
|
+
|
|
132
|
+
const cmd = `"${VENV_PYTHON}" "${SCAN_SCRIPT}" ${args.map(a => `"${a}"`).join(" ")}`;
|
|
133
|
+
try {
|
|
134
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 180_000 });
|
|
135
|
+
return { exitCode: 0, output: (stdout + stderr).trim() };
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
return { exitCode: err.code ?? 1, output: (err.stdout + err.stderr || "").trim() || err.message };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── 日报 ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async function buildDailyReport(dirs: string[], behavioral: boolean, logger: any): Promise<string> {
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const dateStr = now.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
|
|
146
|
+
const timeStr = now.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
|
147
|
+
const jsonOut = join(STATE_DIR, `report-${now.toISOString().slice(0, 10)}.json`);
|
|
148
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
149
|
+
|
|
150
|
+
let total = 0, safe = 0, unsafe = 0, errors = 0;
|
|
151
|
+
const unsafeList: string[] = [];
|
|
152
|
+
const allResults: any[] = [];
|
|
153
|
+
|
|
154
|
+
for (const dir of dirs) {
|
|
155
|
+
const expanded = expandPath(dir);
|
|
156
|
+
if (!existsSync(expanded)) continue;
|
|
157
|
+
const tmpJson = join(STATE_DIR, `tmp-${Date.now()}.json`);
|
|
158
|
+
await runScan("batch", expanded, { behavioral, recursive: true, jsonOut: tmpJson });
|
|
159
|
+
try {
|
|
160
|
+
const rows: any[] = JSON.parse(readFileSync(tmpJson, "utf-8"));
|
|
161
|
+
try { rmSync(tmpJson); } catch {}
|
|
162
|
+
for (const r of rows) {
|
|
163
|
+
allResults.push(r);
|
|
164
|
+
total++;
|
|
165
|
+
if (r.error) errors++;
|
|
166
|
+
else if (r.is_safe) safe++;
|
|
167
|
+
else { unsafe++; unsafeList.push(r.name || basename(r.path ?? "")); }
|
|
168
|
+
}
|
|
169
|
+
} catch { logger.warn(`[skills-scanner] 无法解析 ${tmpJson}`); }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeFileSync(jsonOut, JSON.stringify(allResults, null, 2));
|
|
173
|
+
saveState({ ...loadState(), lastScanAt: now.toISOString(), lastUnsafeSkills: unsafeList });
|
|
174
|
+
|
|
175
|
+
const lines = [
|
|
176
|
+
`🔍 *Skills 安全日报* — ${dateStr} ${timeStr}`,
|
|
177
|
+
"─".repeat(36),
|
|
178
|
+
];
|
|
179
|
+
if (total === 0) {
|
|
180
|
+
lines.push("📭 未找到任何 Skill,请检查扫描目录。");
|
|
181
|
+
} else {
|
|
182
|
+
lines.push(`📊 扫描总计:${total} 个 Skill`);
|
|
183
|
+
lines.push(`✅ 安全:${safe} 个`);
|
|
184
|
+
lines.push(`❌ 问题:${unsafe} 个`);
|
|
185
|
+
if (errors) lines.push(`⚠️ 错误:${errors} 个`);
|
|
186
|
+
if (unsafe > 0) {
|
|
187
|
+
lines.push("", "🚨 *需要关注的 Skills:*");
|
|
188
|
+
for (const name of unsafeList) {
|
|
189
|
+
const r = allResults.find(x => (x.name || basename(x.path ?? "")) === name);
|
|
190
|
+
lines.push(` • ${name} [${r?.max_severity ?? "?"}] — ${r?.findings ?? "?"} 条发现`);
|
|
191
|
+
}
|
|
192
|
+
lines.push("", "💡 运行 `/scan-skill <路径> --detailed` 查看详情");
|
|
193
|
+
} else {
|
|
194
|
+
lines.push("", "🎉 所有 Skills 安全,未发现威胁。");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
lines.push("", `📁 完整报告:${jsonOut}`);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 安装前扫描(fs.watch)────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async function handleNewSkill(
|
|
204
|
+
skillPath: string,
|
|
205
|
+
onUnsafe: "quarantine" | "delete" | "warn",
|
|
206
|
+
behavioral: boolean,
|
|
207
|
+
notifyFn: (msg: string) => void,
|
|
208
|
+
logger: any
|
|
209
|
+
) {
|
|
210
|
+
if (!existsSync(join(skillPath, "SKILL.md"))) return; // 不是合法 Skill
|
|
211
|
+
const name = basename(skillPath);
|
|
212
|
+
logger.info(`[skills-scanner] 🔍 检测到新 Skill,开始安装前扫描: ${name}`);
|
|
213
|
+
notifyFn(`🔍 检测到新 Skill \`${name}\`,正在安全扫描...`);
|
|
214
|
+
|
|
215
|
+
const res = await runScan("scan", skillPath, { behavioral, detailed: true });
|
|
216
|
+
|
|
217
|
+
if (res.exitCode === 0) {
|
|
218
|
+
notifyFn(`✅ \`${name}\` 安全检查通过,可以正常使用。`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 不安全 — 按配置处置
|
|
223
|
+
let action = "";
|
|
224
|
+
try {
|
|
225
|
+
if (onUnsafe === "quarantine") {
|
|
226
|
+
mkdirSync(QUARANTINE_DIR, { recursive: true });
|
|
227
|
+
const dest = join(QUARANTINE_DIR, `${name}-${Date.now()}`);
|
|
228
|
+
renameSync(skillPath, dest);
|
|
229
|
+
action = `已移入隔离目录:\`${dest}\``;
|
|
230
|
+
} else if (onUnsafe === "delete") {
|
|
231
|
+
rmSync(skillPath, { recursive: true, force: true });
|
|
232
|
+
action = "已自动删除";
|
|
233
|
+
} else {
|
|
234
|
+
action = "仅警告,Skill 已保留(请谨慎使用)";
|
|
235
|
+
}
|
|
236
|
+
} catch (e: any) {
|
|
237
|
+
action = `处置失败:${e.message}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
notifyFn([
|
|
241
|
+
`❌ *安全警告:\`${name}\` 未通过扫描*`,
|
|
242
|
+
`处置:${action}`,
|
|
243
|
+
"```",
|
|
244
|
+
res.output.slice(0, 600),
|
|
245
|
+
"```",
|
|
246
|
+
].join("\n"));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function startWatcher(
|
|
250
|
+
dirs: string[],
|
|
251
|
+
onUnsafe: "quarantine" | "delete" | "warn",
|
|
252
|
+
behavioral: boolean,
|
|
253
|
+
notifyFn: (msg: string) => void,
|
|
254
|
+
logger: any
|
|
255
|
+
): () => void {
|
|
256
|
+
const timers = new Map<string, NodeJS.Timeout>();
|
|
257
|
+
|
|
258
|
+
const watchers = dirs.map(dir => {
|
|
259
|
+
const expanded = expandPath(dir);
|
|
260
|
+
if (!existsSync(expanded)) mkdirSync(expanded, { recursive: true });
|
|
261
|
+
logger.info(`[skills-scanner] 👁 监听目录:${expanded}`);
|
|
262
|
+
|
|
263
|
+
return fsWatch(expanded, { persistent: false }, (_evt, filename) => {
|
|
264
|
+
if (!filename) return;
|
|
265
|
+
const skillPath = join(expanded, filename);
|
|
266
|
+
if (!existsSync(skillPath)) return;
|
|
267
|
+
|
|
268
|
+
// 防抖 500ms,等文件写入完毕
|
|
269
|
+
const prev = timers.get(skillPath);
|
|
270
|
+
if (prev) clearTimeout(prev);
|
|
271
|
+
timers.set(skillPath, setTimeout(() => {
|
|
272
|
+
timers.delete(skillPath);
|
|
273
|
+
handleNewSkill(skillPath, onUnsafe, behavioral, notifyFn, logger);
|
|
274
|
+
}, 500));
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return () => {
|
|
279
|
+
watchers.forEach(w => w.close());
|
|
280
|
+
timers.forEach(t => clearTimeout(t));
|
|
281
|
+
timers.clear();
|
|
282
|
+
logger.info("[skills-scanner] 目录监听已停止");
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Plugin 入口 ───────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export default function register(api: any) {
|
|
289
|
+
const cfg: ScannerConfig = api.config?.plugins?.entries?.["skills-scanner"]?.config ?? {};
|
|
290
|
+
const scanDirs = (cfg.scanDirs?.map(expandPath) ?? []).filter(existsSync).length > 0
|
|
291
|
+
? (cfg.scanDirs!.map(expandPath))
|
|
292
|
+
: defaultScanDirs();
|
|
293
|
+
const behavioral = cfg.behavioral ?? false;
|
|
294
|
+
const preInstallScan = cfg.preInstallScan ?? "on";
|
|
295
|
+
const onUnsafe = cfg.onUnsafe ?? "quarantine";
|
|
296
|
+
|
|
297
|
+
// 向聊天发消息的辅助(用于 watcher 通知)——使用 registerCommand handler
|
|
298
|
+
// 文档确认:registerCommand handler 可以直接 return { text },
|
|
299
|
+
// 但 watcher 是异步后台任务,没有 handler context。
|
|
300
|
+
// 唯一有文档依据的方式:写入 event.messages(仅限 hook handler 内)。
|
|
301
|
+
// 这里退而求其次:写到日志 + 持久化到 STATE_FILE,让用户可通过 /scan-status 查看。
|
|
302
|
+
function persistWatcherAlert(msg: string) {
|
|
303
|
+
const state = loadState();
|
|
304
|
+
const alerts: string[] = (state as any).pendingAlerts ?? [];
|
|
305
|
+
alerts.push(`[${new Date().toLocaleString("zh-CN")}] ${msg}`);
|
|
306
|
+
saveState({ ...state, pendingAlerts: alerts } as any);
|
|
307
|
+
api.logger.warn(`[skills-scanner] ${msg}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── 1. 后台服务:安装依赖 + 启动 watcher ─────────────────────────────────
|
|
311
|
+
let stopWatcher: (() => void) | null = null;
|
|
312
|
+
|
|
313
|
+
api.registerService({
|
|
314
|
+
id: "skills-scanner-setup",
|
|
315
|
+
start: async () => {
|
|
316
|
+
await ensureDeps(api.logger);
|
|
317
|
+
|
|
318
|
+
if (preInstallScan === "on" && scanDirs.length > 0) {
|
|
319
|
+
stopWatcher = startWatcher(scanDirs, onUnsafe, behavioral, persistWatcherAlert, api.logger);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
stop: () => { stopWatcher?.(); stopWatcher = null; },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// ── 2. Startup logging ────────────────────────────────────────────────────
|
|
326
|
+
// 在 Gateway 启动时输出配置提示
|
|
327
|
+
api.logger.info("[skills-scanner] Plugin 已加载");
|
|
328
|
+
api.logger.info(`[skills-scanner] 安装前扫描:${preInstallScan === "on" ? `✅ 监听 ${scanDirs.length} 个目录` : "❌ 已禁用"}`);
|
|
329
|
+
|
|
330
|
+
// 检查是否需要注册 Cron Job
|
|
331
|
+
const state = loadState() as any;
|
|
332
|
+
if (!state.cronJobId) {
|
|
333
|
+
api.logger.warn("[skills-scanner] ⚠️ 未检测到日报 Cron Job,请手动注册一次:");
|
|
334
|
+
api.logger.info('[skills-scanner] openclaw cron add --name "skills-daily-report" --cron "0 8 * * *" --tz "Asia/Shanghai" --session isolated --message "请执行 /scan-report 并把结果发送到此渠道" --announce');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── 3. /scan-skill ────────────────────────────────────────────────────────
|
|
338
|
+
api.registerCommand({
|
|
339
|
+
name: "scan-skill",
|
|
340
|
+
description: "扫描单个 Skill 目录。用法: /scan-skill <路径> [--detailed] [--behavioral]",
|
|
341
|
+
acceptsArgs: true,
|
|
342
|
+
requireAuth: true,
|
|
343
|
+
handler: async (ctx: any) => {
|
|
344
|
+
const raw = (ctx.args ?? "").trim();
|
|
345
|
+
if (!raw) return { text: "用法:`/scan-skill <路径> [--detailed] [--behavioral]`" };
|
|
346
|
+
if (!isVenvReady()) return { text: "⏳ Python 依赖尚未就绪,请稍后重试" };
|
|
347
|
+
|
|
348
|
+
const parts = raw.split(/\s+/);
|
|
349
|
+
const skillPath = expandPath(parts.find(p => !p.startsWith("--")) ?? "");
|
|
350
|
+
const detailed = parts.includes("--detailed");
|
|
351
|
+
const useBehav = parts.includes("--behavioral") || behavioral;
|
|
352
|
+
|
|
353
|
+
const res = await runScan("scan", skillPath, { detailed, behavioral: useBehav });
|
|
354
|
+
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
355
|
+
return { text: `${icon} 扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ── 4. /scan-skills ───────────────────────────────────────────────────────
|
|
360
|
+
api.registerCommand({
|
|
361
|
+
name: "scan-skills",
|
|
362
|
+
description: "批量扫描目录下所有 Skills。用法: /scan-skills <目录> [--recursive] [--detailed]",
|
|
363
|
+
acceptsArgs: true,
|
|
364
|
+
requireAuth: true,
|
|
365
|
+
handler: async (ctx: any) => {
|
|
366
|
+
const raw = (ctx.args ?? "").trim();
|
|
367
|
+
if (!raw) return { text: "用法:`/scan-skills <目录> [--recursive] [--detailed]`" };
|
|
368
|
+
if (!isVenvReady()) return { text: "⏳ Python 依赖尚未就绪,请稍后重试" };
|
|
369
|
+
|
|
370
|
+
const parts = raw.split(/\s+/);
|
|
371
|
+
const dirPath = expandPath(parts.find(p => !p.startsWith("--")) ?? "");
|
|
372
|
+
const recursive = parts.includes("--recursive");
|
|
373
|
+
const detailed = parts.includes("--detailed");
|
|
374
|
+
const useBehav = parts.includes("--behavioral") || behavioral;
|
|
375
|
+
|
|
376
|
+
const res = await runScan("batch", dirPath, { recursive, detailed, behavioral: useBehav });
|
|
377
|
+
const icon = res.exitCode === 0 ? "✅" : "❌";
|
|
378
|
+
return { text: `${icon} 批量扫描完成\n\`\`\`\n${res.output}\n\`\`\`` };
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ── 5. /scan-report ───────────────────────────────────────────────────────
|
|
383
|
+
api.registerCommand({
|
|
384
|
+
name: "scan-report",
|
|
385
|
+
description: "立即执行全量扫描并生成安全日报",
|
|
386
|
+
acceptsArgs: false,
|
|
387
|
+
requireAuth: true,
|
|
388
|
+
handler: async (_ctx: any) => {
|
|
389
|
+
if (!isVenvReady()) return { text: "⏳ Python 依赖尚未就绪,请稍后重试" };
|
|
390
|
+
if (scanDirs.length === 0) return { text: "⚠️ 未找到可扫描目录,请检查配置" };
|
|
391
|
+
const report = await buildDailyReport(scanDirs, behavioral, api.logger);
|
|
392
|
+
return { text: report };
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ── 6. /scan-status ───────────────────────────────────────────────────────
|
|
397
|
+
api.registerCommand({
|
|
398
|
+
name: "scan-status",
|
|
399
|
+
description: "查看 Skills Scanner 状态和待查告警",
|
|
400
|
+
acceptsArgs: false,
|
|
401
|
+
requireAuth: true,
|
|
402
|
+
handler: (_ctx: any) => {
|
|
403
|
+
const state = loadState() as any;
|
|
404
|
+
const alerts: string[] = state.pendingAlerts ?? [];
|
|
405
|
+
|
|
406
|
+
const lines = [
|
|
407
|
+
"📋 *Skills Scanner 状态*",
|
|
408
|
+
`Python 依赖:${isVenvReady() ? "✅ 就绪" : "❌ 未就绪"}`,
|
|
409
|
+
`安装前扫描:${preInstallScan === "on" ? `✅ 监听中(${onUnsafe})` : "❌ 已禁用"}`,
|
|
410
|
+
`上次扫描:${state.lastScanAt ? new Date(state.lastScanAt).toLocaleString("zh-CN") : "从未"}`,
|
|
411
|
+
`上次问题 Skills:${state.lastUnsafeSkills?.length ? state.lastUnsafeSkills.join(", ") : "无"}`,
|
|
412
|
+
`扫描目录:\n${scanDirs.map(d => ` • ${d}`).join("\n")}`,
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
if (alerts.length > 0) {
|
|
416
|
+
lines.push("", `🔔 *待查告警(${alerts.length} 条):*`);
|
|
417
|
+
alerts.slice(-5).forEach(a => lines.push(` ${a}`));
|
|
418
|
+
// 读取后清空
|
|
419
|
+
saveState({ ...state, pendingAlerts: [] });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
lines.push(
|
|
423
|
+
"",
|
|
424
|
+
"定期日报(需手动注册一次):",
|
|
425
|
+
"```",
|
|
426
|
+
"openclaw cron add \\",
|
|
427
|
+
' --name "skills-daily-report" \\',
|
|
428
|
+
' --cron "0 8 * * *" --tz "Asia/Shanghai" \\',
|
|
429
|
+
" --session isolated \\",
|
|
430
|
+
' --message "请执行 /scan-report 并把结果发送到此渠道" \\',
|
|
431
|
+
" --announce",
|
|
432
|
+
"```",
|
|
433
|
+
);
|
|
434
|
+
return { text: lines.join("\n") };
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ── 7. Gateway RPC ────────────────────────────────────────────────────────
|
|
439
|
+
api.registerGatewayMethod("skillsScanner.scan", async ({ respond, params }: any) => {
|
|
440
|
+
const { path: p, mode = "scan", recursive = false, detailed = false } = params ?? {};
|
|
441
|
+
if (!p) return respond(false, { error: "缺少 path 参数" });
|
|
442
|
+
if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
|
|
443
|
+
const res = await runScan(mode === "batch" ? "batch" : "scan", expandPath(p), { recursive, detailed, behavioral });
|
|
444
|
+
respond(res.exitCode === 0, { output: res.output, exitCode: res.exitCode, is_safe: res.exitCode === 0 });
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
api.registerGatewayMethod("skillsScanner.report", async ({ respond }: any) => {
|
|
448
|
+
if (!isVenvReady()) return respond(false, { error: "Python 依赖未就绪" });
|
|
449
|
+
if (scanDirs.length === 0) return respond(false, { error: "未找到可扫描目录" });
|
|
450
|
+
const report = await buildDailyReport(scanDirs, behavioral, api.logger);
|
|
451
|
+
respond(true, { report, state: loadState() });
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ── 8. CLI ────────────────────────────────────────────────────────────────
|
|
455
|
+
api.registerCli(({ program }: any) => {
|
|
456
|
+
const cmd = program.command("skills-scan").description("OpenClaw Skills 安全扫描");
|
|
457
|
+
|
|
458
|
+
cmd.command("scan <path>")
|
|
459
|
+
.description("扫描单个 Skill")
|
|
460
|
+
.option("--detailed", "显示所有 findings")
|
|
461
|
+
.option("--behavioral", "启用行为分析")
|
|
462
|
+
.action(async (p: string, opts: any) => {
|
|
463
|
+
const res = await runScan("scan", expandPath(p), opts);
|
|
464
|
+
console.log(res.output);
|
|
465
|
+
process.exit(res.exitCode);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
cmd.command("batch <directory>")
|
|
469
|
+
.description("批量扫描目录")
|
|
470
|
+
.option("--recursive", "递归子目录")
|
|
471
|
+
.option("--detailed", "显示所有 findings")
|
|
472
|
+
.option("--behavioral", "启用行为分析")
|
|
473
|
+
.action(async (d: string, opts: any) => {
|
|
474
|
+
const res = await runScan("batch", expandPath(d), opts);
|
|
475
|
+
console.log(res.output);
|
|
476
|
+
process.exit(res.exitCode);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
cmd.command("report")
|
|
480
|
+
.description("立即执行全量扫描并打印日报")
|
|
481
|
+
.action(async () => {
|
|
482
|
+
const report = await buildDailyReport(scanDirs, behavioral, console);
|
|
483
|
+
console.log(report);
|
|
484
|
+
});
|
|
485
|
+
}, { commands: ["skills-scan"] });
|
|
486
|
+
|
|
487
|
+
api.logger.info("[skills-scanner] Plugin 已注册 ✅");
|
|
488
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "skills-scanner",
|
|
3
|
+
"name": "Skills Security Scanner",
|
|
4
|
+
"description": "Skills 安全扫描:安装前扫描(fs.watch)、按需扫描、安全日报",
|
|
5
|
+
"skills": ["./skills"],
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"scanDirs": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "type": "string" },
|
|
13
|
+
"description": "扫描目录列表,留空自动检测 ~/.openclaw/skills"
|
|
14
|
+
},
|
|
15
|
+
"behavioral": {
|
|
16
|
+
"type": "boolean",
|
|
17
|
+
"default": false,
|
|
18
|
+
"description": "启用 AST 行为分析(更准确但较慢)"
|
|
19
|
+
},
|
|
20
|
+
"preInstallScan": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"enum": ["on", "off"],
|
|
23
|
+
"default": "on",
|
|
24
|
+
"description": "是否启用 fs.watch 安装前扫描"
|
|
25
|
+
},
|
|
26
|
+
"onUnsafe": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"enum": ["quarantine", "delete", "warn"],
|
|
29
|
+
"default": "quarantine",
|
|
30
|
+
"description": "发现不安全 Skill 时的处置:隔离 / 删除 / 仅警告"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"uiHints": {
|
|
35
|
+
"scanDirs": { "label": "扫描目录(留空自动检测)" },
|
|
36
|
+
"behavioral": { "label": "启用行为分析器" },
|
|
37
|
+
"preInstallScan": { "label": "安装前扫描(fs.watch)" },
|
|
38
|
+
"onUnsafe": { "label": "不安全 Skill 的处置方式" }
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pwddd/skills-scanner",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "OpenClaw Plugin:Skills 安全扫描、安装前拦截、安全日报",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.ts",
|
|
8
|
+
"openclaw.plugin.json",
|
|
9
|
+
"hooks/**",
|
|
10
|
+
"skills/**"
|
|
11
|
+
],
|
|
12
|
+
"openclaw": {
|
|
13
|
+
"extensions": ["./index.ts"]
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["openclaw", "openclaw-plugin", "security", "skill-scanner"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skills-scanner
|
|
3
|
+
description: Security scanner for OpenClaw Skills using Cisco AI Skill Scanner. Supports single skill scan and batch scan of multiple skills.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
user-invocable: true
|
|
6
|
+
metadata: {"openclaw": {"emoji": "🔍", "requires": {"bins": ["uv", "python3"]}, "install": [{"id": "uv-brew", "kind": "brew", "formula": "uv", "bins": ["uv"], "label": "Install uv (macOS)", "os": ["darwin"]}, {"id": "uv-curl", "kind": "download", "url": "https://astral.sh/uv/install.sh", "label": "Install uv (Linux)", "os": ["linux"]}]}}
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skills Security Scanner 🔍
|
|
10
|
+
|
|
11
|
+
使用 Cisco AI Skill Scanner 对 OpenClaw Skills 进行安全扫描,检测恶意代码、数据窃取、提示注入等威胁。
|
|
12
|
+
|
|
13
|
+
## 环境准备(首次使用)
|
|
14
|
+
|
|
15
|
+
在首次运行任何扫描命令前,先检查并安装依赖:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 检查 uv 是否可用
|
|
19
|
+
which uv || echo "请先安装 uv: brew install uv 或 curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
20
|
+
|
|
21
|
+
# 安装 cisco-ai-skill-scanner 到隔离虚拟环境
|
|
22
|
+
uv venv {baseDir}/.venv --python 3.10 --quiet
|
|
23
|
+
uv pip install --python {baseDir}/.venv/bin/python cisco-ai-skill-scanner --quiet
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
安装只需执行一次,后续直接使用即可。
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 单个 Skill 扫描
|
|
31
|
+
|
|
32
|
+
**触发词**: 用户说"扫描 skill"、"检查这个 skill"、"scan skill"、"安全检查 [路径]"
|
|
33
|
+
|
|
34
|
+
### 基础扫描(推荐,速度快)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py scan <skill路径>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 详细模式(显示所有 findings)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py scan <skill路径> --detailed
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 深度扫描(加入行为分析)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py scan <skill路径> --detailed --behavioral
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 最强扫描(加入 LLM 语义分析,需要 API Key)
|
|
53
|
+
|
|
54
|
+
在使用 `--llm` 前,确认环境变量已设置:
|
|
55
|
+
```bash
|
|
56
|
+
export SKILL_SCANNER_LLM_API_KEY="your-api-key"
|
|
57
|
+
export SKILL_SCANNER_LLM_MODEL="claude-3-5-sonnet-20241022"
|
|
58
|
+
```
|
|
59
|
+
然后运行:
|
|
60
|
+
```bash
|
|
61
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py scan <skill路径> --detailed --behavioral --llm
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 批量扫描
|
|
67
|
+
|
|
68
|
+
**触发词**: 用户说"批量扫描"、"扫描所有 skills"、"scan all"、"检查 skills 目录"
|
|
69
|
+
|
|
70
|
+
### 扫描指定目录下的所有 Skills
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py batch <目录路径>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 递归扫描(含子目录)
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py batch <目录路径> --recursive
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 批量扫描并输出 JSON 报告
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py batch <目录路径> --detailed --json /tmp/scan-report.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 常用目录示例
|
|
89
|
+
|
|
90
|
+
扫描 OpenClaw 默认 skills 目录:
|
|
91
|
+
```bash
|
|
92
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py batch ~/.openclaw/skills
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
扫描 workspace skills:
|
|
96
|
+
```bash
|
|
97
|
+
{baseDir}/.venv/bin/python {baseDir}/scan.py batch ~/.openclaw/workspace/skills --recursive
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 结果解读
|
|
103
|
+
|
|
104
|
+
| 状态 | 含义 |
|
|
105
|
+
|------|------|
|
|
106
|
+
| ✅ 安全 | 未检测到 HIGH/CRITICAL 问题,可正常使用 |
|
|
107
|
+
| ⚠️ 需关注 | 存在 LOW/MEDIUM 问题,建议人工复核 |
|
|
108
|
+
| ❌ 发现问题 | 存在 HIGH/CRITICAL 威胁,**强烈建议不要安装** |
|
|
109
|
+
|
|
110
|
+
### 严重级别说明
|
|
111
|
+
|
|
112
|
+
- **CRITICAL**: 主动利用尝试(数据窃取、代码注入)
|
|
113
|
+
- **HIGH**: 危险模式(提示注入、未授权访问)
|
|
114
|
+
- **MEDIUM**: 可疑行为(未声明的能力、误导性描述)
|
|
115
|
+
- **LOW**: 轻微风险,需人工判断
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 参数说明
|
|
120
|
+
|
|
121
|
+
| 参数 | 说明 |
|
|
122
|
+
|------|------|
|
|
123
|
+
| `--detailed` | 显示每条 finding 的完整详情 |
|
|
124
|
+
| `--behavioral` | 启用 AST 数据流分析(更准确,稍慢) |
|
|
125
|
+
| `--llm` | 启用 LLM 语义分析(最准确,需 API Key) |
|
|
126
|
+
| `--recursive` | 批量扫描时递归子目录 |
|
|
127
|
+
| `--json <文件>` | 将结果保存为 JSON 文件 |
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 注意事项
|
|
132
|
+
|
|
133
|
+
- **扫描结果不等于安全保证**。`is_safe=True` 表示未检测到已知威胁模式,不代表 skill 绝对安全。
|
|
134
|
+
- 扫描使用静态分析,不会执行任何 skill 中的代码。
|
|
135
|
+
- 如需 LLM 分析,任何兼容 OpenAI 格式的 API Key 均可使用(Anthropic、OpenAI、Azure 等)。
|
|
136
|
+
- 退出码 `0` 表示安全,`1` 表示存在问题(便于 CI/CD 集成)。
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# dependencies = [
|
|
4
|
+
# "cisco-ai-skill-scanner>=0.1.0",
|
|
5
|
+
# ]
|
|
6
|
+
# ///
|
|
7
|
+
"""
|
|
8
|
+
OpenClaw Skills Security Scanner
|
|
9
|
+
基于 cisco-ai-skill-scanner,支持单个/批量扫描
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
import json
|
|
15
|
+
import argparse
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# ── 依赖检查 ──────────────────────────────────────────────────────────────────
|
|
19
|
+
try:
|
|
20
|
+
from skill_scanner import SkillScanner
|
|
21
|
+
from skill_scanner.core.analyzers import (
|
|
22
|
+
StaticAnalyzer,
|
|
23
|
+
BehavioralAnalyzer,
|
|
24
|
+
PipelineAnalyzer,
|
|
25
|
+
)
|
|
26
|
+
except ImportError:
|
|
27
|
+
print("❌ cisco-ai-skill-scanner 未安装。")
|
|
28
|
+
print(" 请运行: uv pip install cisco-ai-skill-scanner")
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── 颜色输出 ──────────────────────────────────────────────────────────────────
|
|
33
|
+
USE_COLOR = sys.stdout.isatty()
|
|
34
|
+
|
|
35
|
+
def c(text, code):
|
|
36
|
+
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
|
|
37
|
+
|
|
38
|
+
RED = lambda t: c(t, "31")
|
|
39
|
+
YELLOW = lambda t: c(t, "33")
|
|
40
|
+
GREEN = lambda t: c(t, "32")
|
|
41
|
+
CYAN = lambda t: c(t, "36")
|
|
42
|
+
BOLD = lambda t: c(t, "1")
|
|
43
|
+
DIM = lambda t: c(t, "2")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── 严重级别 ──────────────────────────────────────────────────────────────────
|
|
47
|
+
SEVERITY_COLORS = {
|
|
48
|
+
"CRITICAL": RED,
|
|
49
|
+
"HIGH": RED,
|
|
50
|
+
"MEDIUM": YELLOW,
|
|
51
|
+
"LOW": GREEN,
|
|
52
|
+
"INFO": CYAN,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def severity_label(sev: str) -> str:
|
|
56
|
+
sev = (sev or "INFO").upper()
|
|
57
|
+
color = SEVERITY_COLORS.get(sev, CYAN)
|
|
58
|
+
return color(f"[{sev}]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── 构建 Scanner ──────────────────────────────────────────────────────────────
|
|
62
|
+
def build_scanner(use_behavioral: bool = False, use_llm: bool = False) -> SkillScanner:
|
|
63
|
+
analyzers = [StaticAnalyzer(), PipelineAnalyzer()]
|
|
64
|
+
if use_behavioral:
|
|
65
|
+
analyzers.append(BehavioralAnalyzer())
|
|
66
|
+
if use_llm:
|
|
67
|
+
try:
|
|
68
|
+
from skill_scanner.core.analyzers import LLMAnalyzer
|
|
69
|
+
api_key = os.environ.get("SKILL_SCANNER_LLM_API_KEY")
|
|
70
|
+
if not api_key:
|
|
71
|
+
print(YELLOW("⚠️ 未设置 SKILL_SCANNER_LLM_API_KEY,跳过 LLM 分析器"))
|
|
72
|
+
else:
|
|
73
|
+
analyzers.append(LLMAnalyzer())
|
|
74
|
+
except ImportError:
|
|
75
|
+
print(YELLOW("⚠️ LLM 分析器不可用,请安装: pip install cisco-ai-skill-scanner[llm]"))
|
|
76
|
+
return SkillScanner(analyzers=analyzers)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── 单个 Skill 扫描 ───────────────────────────────────────────────────────────
|
|
80
|
+
def scan_single(path: str, args) -> dict:
|
|
81
|
+
skill_path = Path(path).expanduser().resolve()
|
|
82
|
+
|
|
83
|
+
if not skill_path.exists():
|
|
84
|
+
return {"path": str(skill_path), "error": "路径不存在", "is_safe": False}
|
|
85
|
+
|
|
86
|
+
if not skill_path.is_dir():
|
|
87
|
+
return {"path": str(skill_path), "error": "必须是目录(Skill 文件夹)", "is_safe": False}
|
|
88
|
+
|
|
89
|
+
skill_md = skill_path / "SKILL.md"
|
|
90
|
+
if not skill_md.exists():
|
|
91
|
+
return {"path": str(skill_path), "error": "未找到 SKILL.md,不是合法的 Skill", "is_safe": False}
|
|
92
|
+
|
|
93
|
+
print(f"\n{BOLD('🔍 扫描:')} {CYAN(str(skill_path))}")
|
|
94
|
+
|
|
95
|
+
scanner = build_scanner(
|
|
96
|
+
use_behavioral=getattr(args, "behavioral", False),
|
|
97
|
+
use_llm=getattr(args, "llm", False),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = scanner.scan_skill(str(skill_path))
|
|
101
|
+
findings = result.findings if hasattr(result, "findings") else []
|
|
102
|
+
max_sev = str(result.max_severity) if hasattr(result, "max_severity") else "UNKNOWN"
|
|
103
|
+
is_safe = bool(result.is_safe) if hasattr(result, "is_safe") else len(findings) == 0
|
|
104
|
+
|
|
105
|
+
# 状态行
|
|
106
|
+
if is_safe:
|
|
107
|
+
status = GREEN("✅ 安全")
|
|
108
|
+
else:
|
|
109
|
+
status = RED("❌ 发现问题") if max_sev in ("CRITICAL", "HIGH") else YELLOW("⚠️ 需关注")
|
|
110
|
+
|
|
111
|
+
print(f" 状态: {status} | 最高严重级别: {severity_label(max_sev)} | 发现: {len(findings)} 条")
|
|
112
|
+
|
|
113
|
+
# 详细 findings
|
|
114
|
+
if findings and getattr(args, "detailed", False):
|
|
115
|
+
print(f"\n {BOLD('发现详情:')}")
|
|
116
|
+
for i, f in enumerate(findings, 1):
|
|
117
|
+
sev = str(getattr(f, "severity", "INFO")).upper()
|
|
118
|
+
name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
|
|
119
|
+
desc = getattr(f, "description", getattr(f, "message", ""))
|
|
120
|
+
loc = getattr(f, "location", getattr(f, "file", ""))
|
|
121
|
+
print(f" {DIM(str(i)+'.')} {severity_label(sev)} {BOLD(name)}")
|
|
122
|
+
if desc:
|
|
123
|
+
print(f" {desc}")
|
|
124
|
+
if loc:
|
|
125
|
+
print(f" {DIM('位置: ' + str(loc))}")
|
|
126
|
+
elif findings and not getattr(args, "detailed", False):
|
|
127
|
+
# 非 detailed 模式只显示 HIGH/CRITICAL
|
|
128
|
+
serious = [f for f in findings if str(getattr(f, "severity", "")).upper() in ("CRITICAL", "HIGH")]
|
|
129
|
+
if serious:
|
|
130
|
+
print(f"\n {BOLD('HIGH/CRITICAL 问题:')}")
|
|
131
|
+
for f in serious:
|
|
132
|
+
sev = str(getattr(f, "severity", "HIGH")).upper()
|
|
133
|
+
name = getattr(f, "rule_id", getattr(f, "name", "unknown"))
|
|
134
|
+
desc = getattr(f, "description", getattr(f, "message", ""))
|
|
135
|
+
print(f" {severity_label(sev)} {BOLD(name)}: {desc}")
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"path": str(skill_path),
|
|
139
|
+
"name": skill_path.name,
|
|
140
|
+
"is_safe": is_safe,
|
|
141
|
+
"max_severity": max_sev,
|
|
142
|
+
"findings": len(findings),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── 批量扫描 ──────────────────────────────────────────────────────────────────
|
|
147
|
+
def scan_batch(directory: str, args) -> list[dict]:
|
|
148
|
+
base = Path(directory).expanduser().resolve()
|
|
149
|
+
|
|
150
|
+
if not base.exists() or not base.is_dir():
|
|
151
|
+
print(RED(f"❌ 目录不存在: {base}"))
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
# 查找所有含 SKILL.md 的子目录
|
|
155
|
+
skills: list[Path] = []
|
|
156
|
+
|
|
157
|
+
if getattr(args, "recursive", False):
|
|
158
|
+
for skill_md in sorted(base.rglob("SKILL.md")):
|
|
159
|
+
skills.append(skill_md.parent)
|
|
160
|
+
else:
|
|
161
|
+
for entry in sorted(base.iterdir()):
|
|
162
|
+
if entry.is_dir() and (entry / "SKILL.md").exists():
|
|
163
|
+
skills.append(entry)
|
|
164
|
+
|
|
165
|
+
if not skills:
|
|
166
|
+
print(YELLOW(f"⚠️ 在 {base} 中未找到任何 Skill(含 SKILL.md 的目录)"))
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
print(f"\n{BOLD('📂 批量扫描目录:')} {CYAN(str(base))}")
|
|
170
|
+
print(f" 发现 {BOLD(str(len(skills)))} 个 Skill\n")
|
|
171
|
+
print("─" * 60)
|
|
172
|
+
|
|
173
|
+
results = []
|
|
174
|
+
safe_count = 0
|
|
175
|
+
unsafe_count = 0
|
|
176
|
+
error_count = 0
|
|
177
|
+
|
|
178
|
+
for skill_path in skills:
|
|
179
|
+
r = scan_single(str(skill_path), args)
|
|
180
|
+
results.append(r)
|
|
181
|
+
if r.get("error"):
|
|
182
|
+
error_count += 1
|
|
183
|
+
elif r.get("is_safe"):
|
|
184
|
+
safe_count += 1
|
|
185
|
+
else:
|
|
186
|
+
unsafe_count += 1
|
|
187
|
+
|
|
188
|
+
# 汇总
|
|
189
|
+
print("\n" + "═" * 60)
|
|
190
|
+
print(BOLD("📊 批量扫描汇总"))
|
|
191
|
+
print("═" * 60)
|
|
192
|
+
print(f" 总计: {len(results)} 个 Skill")
|
|
193
|
+
print(f" {GREEN('✅ 安全:')} {safe_count} 个")
|
|
194
|
+
print(f" {RED('❌ 问题:')} {unsafe_count} 个")
|
|
195
|
+
if error_count:
|
|
196
|
+
print(f" {YELLOW('⚠️ 错误:')} {error_count} 个")
|
|
197
|
+
|
|
198
|
+
# 问题 Skill 列表
|
|
199
|
+
unsafe = [r for r in results if not r.get("is_safe") and not r.get("error")]
|
|
200
|
+
if unsafe:
|
|
201
|
+
print(f"\n {BOLD(RED('需要关注的 Skills:'))}")
|
|
202
|
+
for r in unsafe:
|
|
203
|
+
sev = r.get("max_severity", "UNKNOWN")
|
|
204
|
+
print(f" {severity_label(sev)} {r['name']} ({r.get('findings', 0)} 条发现)")
|
|
205
|
+
print(f" {DIM(r['path'])}")
|
|
206
|
+
|
|
207
|
+
return results
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── JSON 输出 ─────────────────────────────────────────────────────────────────
|
|
211
|
+
def save_json(data, output_path: str):
|
|
212
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
213
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
214
|
+
print(f"\n{GREEN('💾 结果已保存:')} {output_path}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── CLI 入口 ──────────────────────────────────────────────────────────────────
|
|
218
|
+
def main():
|
|
219
|
+
parser = argparse.ArgumentParser(
|
|
220
|
+
prog="scan.py",
|
|
221
|
+
description="OpenClaw Skills 安全扫描器(基于 cisco-ai-skill-scanner)",
|
|
222
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
223
|
+
epilog="""
|
|
224
|
+
示例:
|
|
225
|
+
单个扫描:
|
|
226
|
+
python scan.py scan ~/.openclaw/skills/my-skill
|
|
227
|
+
python scan.py scan ./my-skill --detailed --behavioral
|
|
228
|
+
|
|
229
|
+
批量扫描:
|
|
230
|
+
python scan.py batch ~/.openclaw/skills
|
|
231
|
+
python scan.py batch ~/.openclaw/skills --recursive --json results.json
|
|
232
|
+
python scan.py batch ./skills --detailed --llm
|
|
233
|
+
""",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
237
|
+
|
|
238
|
+
# -- scan 子命令
|
|
239
|
+
p_scan = sub.add_parser("scan", help="扫描单个 Skill 目录")
|
|
240
|
+
p_scan.add_argument("path", help="Skill 目录路径(含 SKILL.md 的文件夹)")
|
|
241
|
+
p_scan.add_argument("--detailed", action="store_true", help="显示所有 findings 详情")
|
|
242
|
+
p_scan.add_argument("--behavioral", action="store_true", help="启用行为分析器(AST dataflow)")
|
|
243
|
+
p_scan.add_argument("--llm", action="store_true", help="启用 LLM 语义分析器(需要 API Key)")
|
|
244
|
+
p_scan.add_argument("--json", metavar="FILE", help="将结果保存为 JSON 文件")
|
|
245
|
+
|
|
246
|
+
# -- batch 子命令
|
|
247
|
+
p_batch = sub.add_parser("batch", help="批量扫描目录下所有 Skills")
|
|
248
|
+
p_batch.add_argument("directory", help="包含多个 Skill 的目录")
|
|
249
|
+
p_batch.add_argument("--recursive", action="store_true", help="递归扫描子目录")
|
|
250
|
+
p_batch.add_argument("--detailed", action="store_true", help="显示所有 findings 详情")
|
|
251
|
+
p_batch.add_argument("--behavioral", action="store_true", help="启用行为分析器")
|
|
252
|
+
p_batch.add_argument("--llm", action="store_true", help="启用 LLM 语义分析器")
|
|
253
|
+
p_batch.add_argument("--json", metavar="FILE", help="将结果保存为 JSON 文件")
|
|
254
|
+
|
|
255
|
+
args = parser.parse_args()
|
|
256
|
+
|
|
257
|
+
if args.command == "scan":
|
|
258
|
+
result = scan_single(args.path, args)
|
|
259
|
+
if args.json:
|
|
260
|
+
save_json(result, args.json)
|
|
261
|
+
# 退出码:不安全返回 1
|
|
262
|
+
sys.exit(0 if result.get("is_safe") else 1)
|
|
263
|
+
|
|
264
|
+
elif args.command == "batch":
|
|
265
|
+
results = scan_batch(args.directory, args)
|
|
266
|
+
if args.json:
|
|
267
|
+
save_json(results, args.json)
|
|
268
|
+
unsafe = [r for r in results if not r.get("is_safe")]
|
|
269
|
+
sys.exit(0 if not unsafe else 1)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|