@pwddd/skills-scanner 1.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 +392 -0
- package/index.ts +373 -0
- package/openclaw.plugin.json +60 -0
- package/package.json +49 -0
- package/skills/skills-scanner/SKILL.md +180 -0
- package/skills/skills-scanner/scan.py +373 -0
- package/src/commands.ts +277 -0
- package/src/config.ts +170 -0
- package/src/cron.ts +143 -0
- package/src/deps.ts +73 -0
- package/src/prompt-guidance.ts +25 -0
- package/src/report.ts +100 -0
- package/src/scanner.ts +50 -0
- package/src/state.ts +70 -0
- package/src/types.ts +48 -0
- package/src/watcher.ts +125 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置管理模块
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
7
|
+
import type { ScannerConfig } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export const skillsScannerConfigSchema: OpenClawPluginConfigSchema = {
|
|
10
|
+
safeParse: (value: unknown) => {
|
|
11
|
+
try {
|
|
12
|
+
const config = value as ScannerConfig;
|
|
13
|
+
|
|
14
|
+
// 验证 policy
|
|
15
|
+
if (config.policy && !["strict", "balanced", "permissive"].includes(config.policy)) {
|
|
16
|
+
return {
|
|
17
|
+
success: false,
|
|
18
|
+
error: {
|
|
19
|
+
issues: [{
|
|
20
|
+
path: ["policy"],
|
|
21
|
+
message: "policy 必须是 strict、balanced 或 permissive"
|
|
22
|
+
}]
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 验证 preInstallScan
|
|
28
|
+
if (config.preInstallScan && !["on", "off"].includes(config.preInstallScan)) {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
error: {
|
|
32
|
+
issues: [{
|
|
33
|
+
path: ["preInstallScan"],
|
|
34
|
+
message: "preInstallScan 必须是 on 或 off"
|
|
35
|
+
}]
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 验证 onUnsafe
|
|
41
|
+
if (config.onUnsafe && !["quarantine", "delete", "warn"].includes(config.onUnsafe)) {
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error: {
|
|
45
|
+
issues: [{
|
|
46
|
+
path: ["onUnsafe"],
|
|
47
|
+
message: "onUnsafe 必须是 quarantine、delete 或 warn"
|
|
48
|
+
}]
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { success: true, data: config };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
error: {
|
|
58
|
+
issues: [{
|
|
59
|
+
path: [],
|
|
60
|
+
message: String(err)
|
|
61
|
+
}]
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
uiHints: {
|
|
68
|
+
apiUrl: {
|
|
69
|
+
label: "API 服务地址",
|
|
70
|
+
help: "扫描 API 服务的 URL 地址",
|
|
71
|
+
placeholder: "http://localhost:8000"
|
|
72
|
+
},
|
|
73
|
+
scanDirs: {
|
|
74
|
+
label: "扫描目录",
|
|
75
|
+
help: "要监控的 Skills 目录列表,支持 ~ 路径"
|
|
76
|
+
},
|
|
77
|
+
behavioral: {
|
|
78
|
+
label: "行为分析",
|
|
79
|
+
help: "启用深度行为分析(较慢但更准确)"
|
|
80
|
+
},
|
|
81
|
+
useLLM: {
|
|
82
|
+
label: "LLM 分析",
|
|
83
|
+
help: "使用 LLM 进行语义分析"
|
|
84
|
+
},
|
|
85
|
+
policy: {
|
|
86
|
+
label: "扫描策略",
|
|
87
|
+
help: "strict=严格 / balanced=平衡(推荐)/ permissive=宽松"
|
|
88
|
+
},
|
|
89
|
+
preInstallScan: {
|
|
90
|
+
label: "安装前扫描",
|
|
91
|
+
help: "监听新 Skill 并自动扫描"
|
|
92
|
+
},
|
|
93
|
+
onUnsafe: {
|
|
94
|
+
label: "不安全处理",
|
|
95
|
+
help: "quarantine=隔离(推荐)/ delete=删除 / warn=仅警告"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function generateConfigGuide(
|
|
101
|
+
cfg: ScannerConfig,
|
|
102
|
+
apiUrl: string,
|
|
103
|
+
scanDirs: string[],
|
|
104
|
+
behavioral: boolean,
|
|
105
|
+
useLLM: boolean,
|
|
106
|
+
policy: string,
|
|
107
|
+
preInstallScan: string,
|
|
108
|
+
onUnsafe: string
|
|
109
|
+
): string {
|
|
110
|
+
return [
|
|
111
|
+
"",
|
|
112
|
+
"╔════════════════════════════════════════════════════════════════╗",
|
|
113
|
+
"║ 🎉 Skills Scanner 首次运行 - 配置向导 ║",
|
|
114
|
+
"╚════════════════════════════════════════════════════════════════╝",
|
|
115
|
+
"",
|
|
116
|
+
"当前使用默认配置。建议根据您的需求自定义配置:",
|
|
117
|
+
"",
|
|
118
|
+
"📋 当前配置:",
|
|
119
|
+
` • API 服务地址: ${apiUrl}`,
|
|
120
|
+
` • 扫描目录: ${scanDirs.length} 个(自动检测)`,
|
|
121
|
+
` • 行为分析: ${behavioral ? "✅ 启用" : "❌ 禁用"}`,
|
|
122
|
+
` • LLM 分析: ${useLLM ? "✅ 启用" : "❌ 禁用"}`,
|
|
123
|
+
` • 扫描策略: ${policy}`,
|
|
124
|
+
` • 安装前扫描: ${preInstallScan === "on" ? "✅ 启用" : "❌ 禁用"}`,
|
|
125
|
+
` • 不安全处理: ${onUnsafe}`,
|
|
126
|
+
"",
|
|
127
|
+
"🔧 配置文件位置:",
|
|
128
|
+
" ~/.openclaw/config.json",
|
|
129
|
+
"",
|
|
130
|
+
"📝 推荐配置示例:",
|
|
131
|
+
"",
|
|
132
|
+
"```json",
|
|
133
|
+
"{",
|
|
134
|
+
' "plugins": {',
|
|
135
|
+
' "entries": {',
|
|
136
|
+
' "skills-scanner": {',
|
|
137
|
+
' "enabled": true,',
|
|
138
|
+
' "config": {',
|
|
139
|
+
' "apiUrl": "http://localhost:8000",',
|
|
140
|
+
' "scanDirs": ["~/.openclaw/skills"],',
|
|
141
|
+
' "behavioral": false,',
|
|
142
|
+
' "useLLM": false,',
|
|
143
|
+
' "policy": "balanced",',
|
|
144
|
+
' "preInstallScan": "on",',
|
|
145
|
+
' "onUnsafe": "quarantine"',
|
|
146
|
+
' }',
|
|
147
|
+
' }',
|
|
148
|
+
' }',
|
|
149
|
+
' }',
|
|
150
|
+
"}",
|
|
151
|
+
"```",
|
|
152
|
+
"",
|
|
153
|
+
"💡 配置说明:",
|
|
154
|
+
"",
|
|
155
|
+
"1. apiUrl 默认 http://localhost:8000,需先启动 skill-scanner-api 服务",
|
|
156
|
+
"2. scanDirs 可添加多个目录(默认自动检测 ~/.openclaw/skills)",
|
|
157
|
+
"3. behavioral false=快速扫描(推荐),true=深度分析",
|
|
158
|
+
"4. useLLM false=不使用 LLM(推荐),true=语义分析",
|
|
159
|
+
"5. policy strict / balanced(推荐)/ permissive",
|
|
160
|
+
"6. preInstallScan on=监听新 Skill 并自动扫描(推荐),off=禁用",
|
|
161
|
+
"7. onUnsafe quarantine=隔离(推荐),delete=删除,warn=仅警告",
|
|
162
|
+
"",
|
|
163
|
+
"🚀 快速开始:",
|
|
164
|
+
" 编辑配置文件后重启 Gateway",
|
|
165
|
+
" /skills-scanner status",
|
|
166
|
+
"",
|
|
167
|
+
"提示:此消息只在首次运行时显示。",
|
|
168
|
+
"════════════════════════════════════════════════════════════════",
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron job management module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { loadState, saveState } from "./state.js";
|
|
7
|
+
|
|
8
|
+
const CRON_JOB_NAME = "skills-daily-report";
|
|
9
|
+
const CRON_SCHEDULE = "0 8 * * *";
|
|
10
|
+
const CRON_TIMEZONE = "Asia/Shanghai";
|
|
11
|
+
|
|
12
|
+
export async function ensureCronJob(logger: any): Promise<void> {
|
|
13
|
+
const state = loadState() as any;
|
|
14
|
+
|
|
15
|
+
logger.info("[skills-scanner] ─────────────────────────────────────");
|
|
16
|
+
logger.info("[skills-scanner] 🕐 Checking cron job...");
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
let jobs: any[] = [];
|
|
20
|
+
try {
|
|
21
|
+
const listResult = execSync("openclaw cron list --format json", {
|
|
22
|
+
encoding: "utf-8",
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
});
|
|
25
|
+
jobs = JSON.parse(listResult.trim());
|
|
26
|
+
} catch (listErr: any) {
|
|
27
|
+
logger.debug("[skills-scanner] JSON format not supported, trying text parsing");
|
|
28
|
+
try {
|
|
29
|
+
const listResult = execSync("openclaw cron list", {
|
|
30
|
+
encoding: "utf-8",
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
});
|
|
33
|
+
if (listResult.includes(CRON_JOB_NAME)) {
|
|
34
|
+
logger.info(`[skills-scanner] ✅ Found existing job: ${CRON_JOB_NAME}`);
|
|
35
|
+
if (!state.cronJobId) {
|
|
36
|
+
saveState({ ...state, cronJobId: "manual-created" });
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
logger.debug("[skills-scanner] Cannot list cron jobs, may be permission issue");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const existingJob = jobs.find(
|
|
46
|
+
(j: any) =>
|
|
47
|
+
j.name === CRON_JOB_NAME ||
|
|
48
|
+
j.jobName === CRON_JOB_NAME ||
|
|
49
|
+
j.id === state.cronJobId
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (existingJob) {
|
|
53
|
+
const jobId = existingJob.id || existingJob.jobId || state.cronJobId;
|
|
54
|
+
|
|
55
|
+
const needsUpdate =
|
|
56
|
+
existingJob.schedule !== CRON_SCHEDULE ||
|
|
57
|
+
existingJob.timezone !== CRON_TIMEZONE;
|
|
58
|
+
|
|
59
|
+
if (needsUpdate) {
|
|
60
|
+
logger.info(`[skills-scanner] 🔄 Job config changed, updating...`);
|
|
61
|
+
try {
|
|
62
|
+
execSync(`openclaw cron remove ${jobId}`, {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
});
|
|
66
|
+
logger.info(`[skills-scanner] ✅ Removed old job: ${jobId}`);
|
|
67
|
+
} catch (removeErr: any) {
|
|
68
|
+
logger.warn(`[skills-scanner] ⚠️ Failed to remove old job: ${removeErr.message}`);
|
|
69
|
+
if (state.cronJobId !== jobId) {
|
|
70
|
+
saveState({ ...state, cronJobId: jobId });
|
|
71
|
+
}
|
|
72
|
+
logger.info(`[skills-scanner] ✅ Keeping existing job: ${jobId}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
if (state.cronJobId !== jobId) {
|
|
77
|
+
saveState({ ...state, cronJobId: jobId });
|
|
78
|
+
logger.info(`[skills-scanner] ✅ Found existing job: ${jobId}`);
|
|
79
|
+
} else {
|
|
80
|
+
logger.info(`[skills-scanner] ✅ Job already exists: ${jobId}`);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
logger.info("[skills-scanner] 📝 Creating cron job...");
|
|
87
|
+
|
|
88
|
+
const cronCmd = [
|
|
89
|
+
"openclaw cron add",
|
|
90
|
+
`--name "${CRON_JOB_NAME}"`,
|
|
91
|
+
`--cron "${CRON_SCHEDULE}"`,
|
|
92
|
+
`--tz "${CRON_TIMEZONE}"`,
|
|
93
|
+
"--session isolated",
|
|
94
|
+
'--message "Please run /skills-scanner scan --report and send results to this channel"',
|
|
95
|
+
"--announce",
|
|
96
|
+
].join(" ");
|
|
97
|
+
|
|
98
|
+
const result = execSync(cronCmd, { encoding: "utf-8", timeout: 10000 });
|
|
99
|
+
|
|
100
|
+
const jobIdMatch =
|
|
101
|
+
result.match(/Job ID[:\s]+([a-zA-Z0-9-]+)/i) ||
|
|
102
|
+
result.match(/jobId[:\s]+([a-zA-Z0-9-]+)/i) ||
|
|
103
|
+
result.match(/id[:\s]+([a-zA-Z0-9-]+)/i);
|
|
104
|
+
|
|
105
|
+
if (jobIdMatch) {
|
|
106
|
+
const cronJobId = jobIdMatch[1];
|
|
107
|
+
saveState({ ...state, cronJobId });
|
|
108
|
+
logger.info(`[skills-scanner] ✅ Job created successfully: ${cronJobId}`);
|
|
109
|
+
logger.info(
|
|
110
|
+
`[skills-scanner] 📅 Schedule: Daily at ${CRON_SCHEDULE.split(" ")[1]}:${CRON_SCHEDULE.split(" ")[0]} (${CRON_TIMEZONE})`
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
logger.info("[skills-scanner] ✅ Job creation command executed");
|
|
114
|
+
logger.debug(`[skills-scanner] Output: ${result.trim()}`);
|
|
115
|
+
saveState({ ...state, cronJobId: "created-unknown-id" });
|
|
116
|
+
}
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
logger.warn("[skills-scanner] ⚠️ Auto-registration failed");
|
|
119
|
+
logger.debug(`[skills-scanner] Error details: ${err.message}`);
|
|
120
|
+
|
|
121
|
+
if (err.message.includes("permission") || err.message.includes("EACCES")) {
|
|
122
|
+
logger.error("[skills-scanner] ❌ Permission denied, please run with admin privileges");
|
|
123
|
+
} else if (
|
|
124
|
+
err.message.includes("command not found") ||
|
|
125
|
+
err.message.includes("ENOENT")
|
|
126
|
+
) {
|
|
127
|
+
logger.error("[skills-scanner] ❌ openclaw command not found, please check installation");
|
|
128
|
+
} else {
|
|
129
|
+
logger.info("[skills-scanner] 💡 Please manually register cron job:");
|
|
130
|
+
logger.info("[skills-scanner]");
|
|
131
|
+
logger.info("[skills-scanner] openclaw cron add \\");
|
|
132
|
+
logger.info(`[skills-scanner] --name "${CRON_JOB_NAME}" \\`);
|
|
133
|
+
logger.info(`[skills-scanner] --cron "${CRON_SCHEDULE}" \\`);
|
|
134
|
+
logger.info(`[skills-scanner] --tz "${CRON_TIMEZONE}" \\`);
|
|
135
|
+
logger.info("[skills-scanner] --session isolated \\");
|
|
136
|
+
logger.info(
|
|
137
|
+
'[skills-scanner] --message "Please run /skills-scanner scan --report and send results to this channel" \\'
|
|
138
|
+
);
|
|
139
|
+
logger.info("[skills-scanner] --announce");
|
|
140
|
+
logger.info("[skills-scanner]");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/deps.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency management module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { exec } from "node:child_process";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
export function hasUv(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
execSync("uv --version", { stdio: "ignore" });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isVenvReady(venvPython: string): boolean {
|
|
23
|
+
if (!existsSync(venvPython)) return false;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
execSync(`"${venvPython}" -c "import requests"`, { stdio: "ignore" });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function ensureDeps(
|
|
34
|
+
skillDir: string,
|
|
35
|
+
venvPython: string,
|
|
36
|
+
logger: any
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
if (isVenvReady(venvPython)) {
|
|
39
|
+
logger.info("[skills-scanner] Python dependencies ready (requests installed)");
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!hasUv()) {
|
|
44
|
+
logger.warn(
|
|
45
|
+
"[skills-scanner] uv not installed: brew install uv or curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
46
|
+
);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logger.info("[skills-scanner] Installing Python dependencies...");
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const venvDir = join(skillDir, ".venv");
|
|
54
|
+
|
|
55
|
+
if (existsSync(venvDir)) {
|
|
56
|
+
logger.info("[skills-scanner] Cleaning old virtual environment...");
|
|
57
|
+
rmSync(venvDir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await execAsync(`uv venv "${venvDir}" --python 3.10`);
|
|
61
|
+
logger.info("[skills-scanner] Virtual environment created");
|
|
62
|
+
|
|
63
|
+
logger.info("[skills-scanner] Installing requests...");
|
|
64
|
+
await execAsync(`uv pip install --python "${venvPython}" requests>=2.31.0`);
|
|
65
|
+
|
|
66
|
+
execSync(`"${venvPython}" -c "import requests"`, { stdio: "ignore" });
|
|
67
|
+
logger.info("[skills-scanner] ✅ Dependencies installed successfully");
|
|
68
|
+
return true;
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
logger.error(`[skills-scanner] ⚠️ Dependency installation failed: ${err.message}`);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt guidance for Skills security scanning
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const SKILLS_SECURITY_GUIDANCE = [
|
|
6
|
+
"## Skills 安全规则",
|
|
7
|
+
"",
|
|
8
|
+
"当用户请求安装、下载或使用新的 Skill 时,必须先运行安全扫描:",
|
|
9
|
+
"",
|
|
10
|
+
"1. 使用 `/skills-scanner scan <skill路径>` 进行扫描",
|
|
11
|
+
"2. 等待扫描结果",
|
|
12
|
+
"3. 根据结果决定:",
|
|
13
|
+
" - ✅ 安全(is_safe=true):可以继续安装,告知用户扫描通过",
|
|
14
|
+
" - ❌ 不安全(is_safe=false):",
|
|
15
|
+
" * 立即告知用户发现的安全问题",
|
|
16
|
+
" * 显示威胁详情(严重级别、发现数量)",
|
|
17
|
+
" * 不得继续安装,除非用户明确要求忽略风险",
|
|
18
|
+
"",
|
|
19
|
+
"对于来自不可信来源的 Skill,使用 `--detailed --behavioral` 进行深度扫描。",
|
|
20
|
+
"",
|
|
21
|
+
"用户明确绕过风险时,必须:",
|
|
22
|
+
"1. 再次明确告知风险",
|
|
23
|
+
"2. 要求用户确认(\"我了解风险并仍要继续\")",
|
|
24
|
+
"3. 建议用户监控该 Skill 的行为",
|
|
25
|
+
].join("\n");
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report generation module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import { runScan } from "./scanner.js";
|
|
8
|
+
import { loadState, saveState, STATE_DIR } from "./state.js";
|
|
9
|
+
import type { ScanRecord } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export async function buildDailyReport(
|
|
12
|
+
dirs: string[],
|
|
13
|
+
behavioral: boolean,
|
|
14
|
+
apiUrl: string,
|
|
15
|
+
useLLM: boolean,
|
|
16
|
+
policy: string,
|
|
17
|
+
logger: any,
|
|
18
|
+
venvPython: string,
|
|
19
|
+
scanScript: string
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const dateStr = now.toLocaleDateString("en-US", {
|
|
23
|
+
year: "numeric",
|
|
24
|
+
month: "2-digit",
|
|
25
|
+
day: "2-digit",
|
|
26
|
+
});
|
|
27
|
+
const timeStr = now.toLocaleTimeString("en-US", {
|
|
28
|
+
hour: "2-digit",
|
|
29
|
+
minute: "2-digit",
|
|
30
|
+
});
|
|
31
|
+
const jsonOut = join(STATE_DIR, `report-${now.toISOString().slice(0, 10)}.json`);
|
|
32
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
33
|
+
|
|
34
|
+
let total = 0;
|
|
35
|
+
let safe = 0;
|
|
36
|
+
let unsafe = 0;
|
|
37
|
+
let errors = 0;
|
|
38
|
+
const unsafeList: string[] = [];
|
|
39
|
+
const allResults: ScanRecord[] = [];
|
|
40
|
+
|
|
41
|
+
for (const dir of dirs) {
|
|
42
|
+
if (!existsSync(dir)) continue;
|
|
43
|
+
const tmpJson = join(STATE_DIR, `tmp-${Date.now()}.json`);
|
|
44
|
+
await runScan(venvPython, scanScript, "batch", dir, {
|
|
45
|
+
behavioral,
|
|
46
|
+
recursive: true,
|
|
47
|
+
jsonOut: tmpJson,
|
|
48
|
+
apiUrl,
|
|
49
|
+
useLLM,
|
|
50
|
+
policy,
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const rows: ScanRecord[] = JSON.parse(readFileSync(tmpJson, "utf-8"));
|
|
54
|
+
try {
|
|
55
|
+
rmSync(tmpJson);
|
|
56
|
+
} catch {}
|
|
57
|
+
for (const r of rows) {
|
|
58
|
+
allResults.push(r);
|
|
59
|
+
total++;
|
|
60
|
+
if (r.error) errors++;
|
|
61
|
+
else if (r.is_safe) safe++;
|
|
62
|
+
else {
|
|
63
|
+
unsafe++;
|
|
64
|
+
unsafeList.push(r.name || basename(r.path ?? ""));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
logger.warn(`[skills-scanner] Cannot parse ${tmpJson}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
writeFileSync(jsonOut, JSON.stringify(allResults, null, 2));
|
|
73
|
+
saveState({
|
|
74
|
+
...loadState(),
|
|
75
|
+
lastScanAt: now.toISOString(),
|
|
76
|
+
lastUnsafeSkills: unsafeList,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const lines = [`🔍 *Skills 安全日报* — ${dateStr} ${timeStr}`, "─".repeat(36)];
|
|
80
|
+
if (total === 0) {
|
|
81
|
+
lines.push("📭 未找到任何 Skill,请检查扫描目录。");
|
|
82
|
+
} else {
|
|
83
|
+
lines.push(`📊 扫描总计:${total} 个 Skill`);
|
|
84
|
+
lines.push(`✅ 安全:${safe} 个`);
|
|
85
|
+
lines.push(`❌ 问题:${unsafe} 个`);
|
|
86
|
+
if (errors) lines.push(`⚠️ 错误:${errors} 个`);
|
|
87
|
+
if (unsafe > 0) {
|
|
88
|
+
lines.push("", "🚨 *需要关注的 Skills:*");
|
|
89
|
+
for (const name of unsafeList) {
|
|
90
|
+
const r = allResults.find((x) => (x.name || basename(x.path ?? "")) === name);
|
|
91
|
+
lines.push(` • ${name} [${r?.max_severity ?? "?"}] — ${r?.findings ?? "?"} 条发现`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("", "💡 运行 `/skills-scanner scan <路径> --detailed` 查看详情");
|
|
94
|
+
} else {
|
|
95
|
+
lines.push("", "🎉 所有 Skills 安全,未发现威胁。");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
lines.push("", `📁 完整报告:${jsonOut}`);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner module - handles Python script execution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { exec } from "node:child_process";
|
|
7
|
+
import type { ScanOptions, ScanResult } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
export async function runScan(
|
|
12
|
+
venvPython: string,
|
|
13
|
+
scanScript: string,
|
|
14
|
+
mode: "scan" | "batch",
|
|
15
|
+
target: string,
|
|
16
|
+
opts: ScanOptions = {}
|
|
17
|
+
): Promise<ScanResult> {
|
|
18
|
+
const args = [mode, target];
|
|
19
|
+
if (opts.detailed) args.push("--detailed");
|
|
20
|
+
if (opts.behavioral) args.push("--behavioral");
|
|
21
|
+
if (opts.recursive) args.push("--recursive");
|
|
22
|
+
if (opts.useLLM) args.push("--llm");
|
|
23
|
+
if (opts.policy) args.push("--policy", opts.policy);
|
|
24
|
+
if (opts.jsonOut) args.push("--json", opts.jsonOut);
|
|
25
|
+
if (opts.apiUrl) args.unshift("--api-url", opts.apiUrl);
|
|
26
|
+
|
|
27
|
+
const cmd = `"${venvPython}" "${scanScript}" ${args.map((a) => `"${a}"`).join(" ")}`;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const env = { ...process.env };
|
|
31
|
+
// Remove proxy env vars to avoid connection issues
|
|
32
|
+
delete env.http_proxy;
|
|
33
|
+
delete env.https_proxy;
|
|
34
|
+
delete env.HTTP_PROXY;
|
|
35
|
+
delete env.HTTPS_PROXY;
|
|
36
|
+
delete env.all_proxy;
|
|
37
|
+
delete env.ALL_PROXY;
|
|
38
|
+
|
|
39
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
40
|
+
timeout: 180_000,
|
|
41
|
+
env,
|
|
42
|
+
});
|
|
43
|
+
return { exitCode: 0, output: (stdout + stderr).trim() };
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
return {
|
|
46
|
+
exitCode: err.code ?? 1,
|
|
47
|
+
output: (err.stdout + err.stderr || "").trim() || err.message,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State management module
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { join, homedir } from "node:path";
|
|
7
|
+
import type { ScanState, ScannerConfig } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const STATE_DIR = join(homedir(), ".openclaw", "skills-scanner");
|
|
10
|
+
const STATE_FILE = join(STATE_DIR, "state.json");
|
|
11
|
+
|
|
12
|
+
export function loadState(): ScanState {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function saveState(s: ScanState): void {
|
|
21
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
22
|
+
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isFirstRun(cfg: ScannerConfig): boolean {
|
|
26
|
+
const state = loadState() as any;
|
|
27
|
+
|
|
28
|
+
if (state.configReviewed) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isDefaultConfig =
|
|
33
|
+
!cfg.apiUrl &&
|
|
34
|
+
(!cfg.scanDirs || cfg.scanDirs.length === 0) &&
|
|
35
|
+
cfg.behavioral !== true &&
|
|
36
|
+
cfg.useLLM !== true &&
|
|
37
|
+
cfg.policy !== "strict" &&
|
|
38
|
+
cfg.policy !== "permissive" &&
|
|
39
|
+
cfg.preInstallScan !== "off" &&
|
|
40
|
+
cfg.onUnsafe !== "delete" &&
|
|
41
|
+
cfg.onUnsafe !== "warn";
|
|
42
|
+
|
|
43
|
+
return isDefaultConfig;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function markConfigReviewed(): void {
|
|
47
|
+
const state = loadState() as any;
|
|
48
|
+
saveState({ ...state, configReviewed: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function expandPath(p: string): string {
|
|
52
|
+
return p.replace(/^~/, homedir());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function defaultScanDirs(): string[] {
|
|
56
|
+
const dirs = [
|
|
57
|
+
join(homedir(), ".openclaw", "skills"),
|
|
58
|
+
join(homedir(), ".openclaw", "workspace", "skills"),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
if (!existsSync(dir)) {
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return dirs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { STATE_DIR, STATE_FILE };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills Scanner 类型定义
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ScannerConfig {
|
|
6
|
+
apiUrl?: string;
|
|
7
|
+
scanDirs?: string[];
|
|
8
|
+
behavioral?: boolean;
|
|
9
|
+
useLLM?: boolean;
|
|
10
|
+
policy?: "strict" | "balanced" | "permissive";
|
|
11
|
+
preInstallScan?: "on" | "off";
|
|
12
|
+
onUnsafe?: "quarantine" | "delete" | "warn";
|
|
13
|
+
injectSecurityGuidance?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ScanState {
|
|
17
|
+
lastScanAt?: string;
|
|
18
|
+
lastUnsafeSkills?: string[];
|
|
19
|
+
configReviewed?: boolean;
|
|
20
|
+
cronJobId?: string;
|
|
21
|
+
pendingAlerts?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ScanOptions {
|
|
25
|
+
detailed?: boolean;
|
|
26
|
+
behavioral?: boolean;
|
|
27
|
+
recursive?: boolean;
|
|
28
|
+
jsonOut?: string;
|
|
29
|
+
apiUrl?: string;
|
|
30
|
+
useLLM?: boolean;
|
|
31
|
+
policy?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScanResult {
|
|
35
|
+
exitCode: number;
|
|
36
|
+
output: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ScanRecord {
|
|
40
|
+
name?: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
is_safe?: boolean;
|
|
43
|
+
error?: string;
|
|
44
|
+
max_severity?: string;
|
|
45
|
+
findings?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type OnUnsafeAction = "quarantine" | "delete" | "warn";
|