@dcrays/mobook-security-plugin 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,597 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ // ============================================================
5
+ // MoBook 安全防护插件 v2.0
6
+ // 功能:危险命令拦截 | 敏感信息脱敏(全渠道) | LLM 响应拦截 | 操作审计日志
7
+ // ============================================================
8
+
9
+ const LOG_DIR = "/tmp/mobook-security";
10
+ const LOG_FILE = path.join(LOG_DIR, "security.log");
11
+ const ALERT_FILE = path.join(LOG_DIR, "alerts.log");
12
+
13
+ // ---- 日志 ----
14
+ function ensureLogDir() {
15
+ try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch {}
16
+ }
17
+
18
+ function log(msg, level = "INFO") {
19
+ ensureLogDir();
20
+ const ts = new Date().toISOString();
21
+ const line = `[${ts}] [${level}] ${msg}\n`;
22
+ try { fs.appendFileSync(LOG_FILE, line); } catch {}
23
+ }
24
+
25
+ function alert(msg) {
26
+ ensureLogDir();
27
+ const ts = new Date().toISOString();
28
+ const line = `[${ts}] [ALERT] ${msg}\n`;
29
+ try {
30
+ fs.appendFileSync(ALERT_FILE, line);
31
+ fs.appendFileSync(LOG_FILE, line);
32
+ } catch {}
33
+ }
34
+
35
+ // ============================================================
36
+ // 1. 危险命令规则库
37
+ // ============================================================
38
+ const DANGEROUS_COMMANDS = [
39
+ // --- 破坏性命令 ---
40
+ { pattern: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+).*\//i, reason: "强制删除路径文件", severity: "CRITICAL" },
41
+ { pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+\//i, reason: "递归删除根路径", severity: "CRITICAL" },
42
+ { pattern: /\brm\s+-rf\s+[~\/]/i, reason: "递归强制删除", severity: "CRITICAL" },
43
+ { pattern: /\bmkfs\b/i, reason: "格式化磁盘", severity: "CRITICAL" },
44
+ { pattern: /\bdd\s+.*of=\/dev\//i, reason: "直接写入设备", severity: "CRITICAL" },
45
+ { pattern: />\s*\/dev\/sd[a-z]/i, reason: "重定向到磁盘设备", severity: "CRITICAL" },
46
+ { pattern: /:(){ :\|:& };:/i, reason: "Fork 炸弹", severity: "CRITICAL" },
47
+
48
+ // --- 权限提升 ---
49
+ { pattern: /\bchmod\s+(-R\s+)?[0-7]*7[0-7]{2}\s+\//i, reason: "对根路径设置危险权限", severity: "HIGH" },
50
+ { pattern: /\bchmod\s+(-R\s+)?777\b/i, reason: "设置 777 权限", severity: "MEDIUM" },
51
+ { pattern: /\bchown\s+-R\s+.*\s+\//i, reason: "递归修改根路径所有者", severity: "HIGH" },
52
+
53
+ // --- 网络相关 ---
54
+ { pattern: /\bcurl\b.*\|\s*(ba)?sh/i, reason: "从网络下载并直接执行脚本", severity: "HIGH" },
55
+ { pattern: /\bwget\b.*\|\s*(ba)?sh/i, reason: "从网络下载并直接执行脚本", severity: "HIGH" },
56
+ { pattern: /\bnc\s+-[a-z]*l[a-z]*\s+-p/i, reason: "开启反向 Shell 监听", severity: "HIGH" },
57
+ { pattern: /\bncat\b.*--exec/i, reason: "ncat 执行命令", severity: "HIGH" },
58
+ { pattern: /\/dev\/tcp\//i, reason: "Bash 反向 Shell", severity: "HIGH" },
59
+
60
+ // --- 数据泄露 ---
61
+ { pattern: /\bcurl\b.*(-d|--data).*password/i, reason: "通过 curl 发送密码", severity: "HIGH" },
62
+ { pattern: /\bcurl\b.*(-d|--data).*secret/i, reason: "通过 curl 发送密钥", severity: "HIGH" },
63
+ { pattern: /\bscp\b.*\/etc\/(passwd|shadow)/i, reason: "拷贝系统密码文件", severity: "CRITICAL" },
64
+ { pattern: /\bcat\s+.*\/(\.ssh\/|id_rsa|\.env|\.aws\/credentials)/i, reason: "读取敏感凭证文件", severity: "HIGH" },
65
+
66
+ // --- 系统破坏 ---
67
+ { pattern: /\bsystemctl\s+(stop|disable)\s+(sshd|firewalld|iptables|ufw)/i, reason: "关闭安全服务", severity: "HIGH" },
68
+ { pattern: /\biptables\s+-F/i, reason: "清空防火墙规则", severity: "HIGH" },
69
+ { pattern: /\bufw\s+disable/i, reason: "关闭防火墙", severity: "HIGH" },
70
+ { pattern: /\bkillall\b/i, reason: "批量杀进程", severity: "MEDIUM" },
71
+ { pattern: /\bshutdown\b|\breboot\b|\binit\s+[06]/i, reason: "关机或重启系统", severity: "HIGH" },
72
+
73
+ // --- 历史记录/审计规避 ---
74
+ { pattern: /\bunset\s+HISTFILE/i, reason: "清除命令历史", severity: "HIGH" },
75
+ { pattern: /\bhistory\s+-c/i, reason: "清除命令历史", severity: "HIGH" },
76
+ { pattern: /\bexport\s+HISTSIZE=0/i, reason: "禁用命令历史", severity: "HIGH" },
77
+ { pattern: />\s*\/var\/log\//i, reason: "清空系统日志", severity: "CRITICAL" },
78
+
79
+ // --- 加密货币挖矿 ---
80
+ { pattern: /\bxmrig\b|\bminerd\b|\bcpuminer\b/i, reason: "加密货币挖矿程序", severity: "CRITICAL" },
81
+ { pattern: /stratum\+tcp:\/\//i, reason: "挖矿矿池连接", severity: "CRITICAL" },
82
+ ];
83
+
84
+ const BLOCK_SEVERITIES = new Set(["CRITICAL", "HIGH"]);
85
+
86
+ function checkDangerousCommand(cmd) {
87
+ for (const rule of DANGEROUS_COMMANDS) {
88
+ if (rule.pattern.test(cmd)) {
89
+ return rule;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ============================================================
96
+ // 2. 校验算法
97
+ // ============================================================
98
+
99
+ const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
100
+ const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
101
+
102
+ function verifyIdCard(idNumber) {
103
+ if (idNumber.length !== 18) return false;
104
+ const body = idNumber.slice(0, 17);
105
+ const checkDigit = idNumber.charAt(17).toUpperCase();
106
+ if (!/^\d{17}$/.test(body)) return false;
107
+ let sum = 0;
108
+ for (let i = 0; i < 17; i++) {
109
+ sum += parseInt(body.charAt(i), 10) * ID_WEIGHTS[i];
110
+ }
111
+ const expected = ID_CHECK_CODES[sum % 11];
112
+ return checkDigit === expected;
113
+ }
114
+
115
+ function verifyLuhn(cardNumber) {
116
+ if (!/^\d{16,19}$/.test(cardNumber)) return false;
117
+ let sum = 0;
118
+ let alternate = false;
119
+ for (let i = cardNumber.length - 1; i >= 0; i--) {
120
+ let n = parseInt(cardNumber.charAt(i), 10);
121
+ if (alternate) {
122
+ n *= 2;
123
+ if (n > 9) n -= 9;
124
+ }
125
+ sum += n;
126
+ alternate = !alternate;
127
+ }
128
+ return sum % 10 === 0;
129
+ }
130
+
131
+ // ============================================================
132
+ // 3. 敏感信息规则库
133
+ // ============================================================
134
+
135
+ const ID_CARD_REGEX = /\b[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g;
136
+ const BANK_CARD_REGEX = /\b[3-6]\d{15,18}\b/g;
137
+
138
+ const SENSITIVE_PATTERNS = [
139
+ {
140
+ pattern: ID_CARD_REGEX,
141
+ validator: verifyIdCard,
142
+ replacementText: "***身份证号已脱敏***",
143
+ name: "身份证号"
144
+ },
145
+ {
146
+ pattern: /\b1[3-9]\d{9}\b/g,
147
+ replacement: "***手机号已脱敏***",
148
+ name: "手机号"
149
+ },
150
+ {
151
+ pattern: BANK_CARD_REGEX,
152
+ validator: verifyLuhn,
153
+ replacementText: "***银行卡号已脱敏***",
154
+ name: "银行卡号"
155
+ },
156
+ {
157
+ pattern: /\b[a-zA-Z0-9._%+-]+@(qq|163|126|sina|sohu|gmail|outlook|hotmail|yahoo)\.[a-z]{2,}\b/gi,
158
+ replacement: "***邮箱已脱敏***",
159
+ name: "个人邮箱"
160
+ },
161
+ {
162
+ pattern: /\b(sk-[a-zA-Z0-9]{20,})\b/g,
163
+ replacement: "***API密钥已脱敏***",
164
+ name: "API Key (sk-)"
165
+ },
166
+ {
167
+ pattern: /\b(AKIA[0-9A-Z]{16})\b/g,
168
+ replacement: "***AWS密钥已脱敏***",
169
+ name: "AWS Access Key"
170
+ },
171
+ {
172
+ pattern: /\b(ghp_[a-zA-Z0-9]{36,})\b/g,
173
+ replacement: "***GitHub Token已脱敏***",
174
+ name: "GitHub Token"
175
+ },
176
+ {
177
+ pattern: /(password|passwd|pwd|secret|token|api_key|apikey|access_key|private_key)\s*[=:]\s*["']?[^\s"',]{6,}/gi,
178
+ replacement: "$1=***已脱敏***",
179
+ name: "密码/密钥字段"
180
+ },
181
+ {
182
+ pattern: /\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g,
183
+ replacement: "***内网IP已脱敏***",
184
+ name: "内网IP"
185
+ },
186
+ ];
187
+
188
+ function sanitizeContent(content) {
189
+ let sanitized = content;
190
+ const matches = [];
191
+
192
+ for (const rule of SENSITIVE_PATTERNS) {
193
+ rule.pattern.lastIndex = 0;
194
+
195
+ if (rule.validator) {
196
+ let hasMatch = false;
197
+ sanitized = sanitized.replace(rule.pattern, (match) => {
198
+ if (rule.validator(match)) {
199
+ hasMatch = true;
200
+ return rule.replacementText;
201
+ }
202
+ return match;
203
+ });
204
+ if (hasMatch) matches.push(rule.name);
205
+ } else if (rule.replacement) {
206
+ rule.pattern.lastIndex = 0;
207
+ const repl = rule.replacement; // 闭包捕获
208
+ const replaced = sanitized.replace(rule.pattern, () => repl);
209
+ if (replaced !== sanitized) {
210
+ matches.push(rule.name);
211
+ sanitized = replaced;
212
+ }
213
+ }
214
+ }
215
+
216
+ return { sanitized, matches };
217
+ }
218
+
219
+ // ============================================================
220
+ // 4. 工具参数脱敏
221
+ // ============================================================
222
+ const SENSITIVE_PARAM_KEYS = ["password", "secret", "token", "api_key", "apikey", "private_key", "access_key"];
223
+
224
+ function sanitizeParamsForLog(params) {
225
+ if (!params || typeof params !== "object") return params;
226
+ const sanitized = { ...params };
227
+ for (const key of Object.keys(sanitized)) {
228
+ if (SENSITIVE_PARAM_KEYS.some(s => key.toLowerCase().includes(s))) {
229
+ sanitized[key] = "***REDACTED***";
230
+ }
231
+ }
232
+ return sanitized;
233
+ }
234
+
235
+ // ============================================================
236
+ // 5. Fetch 劫持 — LLM 请求/响应拦截(全渠道通杀)
237
+ // 借鉴 @alicloud/openclaw-security-assistant 的核心技术
238
+ // ============================================================
239
+
240
+ const FETCH_WRAPPED_KEY = Symbol.for("mobook-security-plugin.fetch-wrapped");
241
+ const ORIGINAL_FETCH_KEY = Symbol.for("mobook-security-plugin.original-fetch");
242
+
243
+ // 保存原始 fetch
244
+ function saveOriginalFetch() {
245
+ if (!globalThis[ORIGINAL_FETCH_KEY] && globalThis.fetch) {
246
+ globalThis[ORIGINAL_FETCH_KEY] = globalThis.fetch;
247
+ }
248
+ return globalThis[ORIGINAL_FETCH_KEY];
249
+ }
250
+
251
+ // 从 fetch 参数提取 URL
252
+ function getUrlFromFetchArgs(input) {
253
+ return typeof input === "string" ? input
254
+ : input instanceof URL ? input.toString()
255
+ : input instanceof Request ? input.url
256
+ : String(input);
257
+ }
258
+
259
+ // 判断是否是 SSE 流式响应
260
+ function isSseResponse(resp) {
261
+ const ct = (resp.headers.get("content-type") || "").toLowerCase();
262
+ return ct.includes("text/event-stream");
263
+ }
264
+
265
+ // 判断请求是否期望 SSE 响应
266
+ function guessRequestWantsSse(url, headers, bodyText) {
267
+ try {
268
+ if (bodyText) {
269
+ const obj = JSON.parse(bodyText);
270
+ if (obj?.stream === true) return true;
271
+ }
272
+ } catch {}
273
+ const accept = (headers["accept"] || headers["Accept"] || "").toLowerCase();
274
+ if (accept.includes("text/event-stream")) return true;
275
+ if (url.includes("/chat/completions")) return true;
276
+ return false;
277
+ }
278
+
279
+ // 从响应头提取为普通对象
280
+ function headersToRecord(headers) {
281
+ const out = {};
282
+ try { headers.forEach((v, k) => out[k] = v); } catch {}
283
+ return out;
284
+ }
285
+
286
+ // 合并请求头
287
+ function getMergedRequestHeaders(input, init) {
288
+ const initHeaders = {};
289
+ if (init?.headers) {
290
+ if (init.headers instanceof Headers) {
291
+ init.headers.forEach((v, k) => initHeaders[k] = v);
292
+ } else if (Array.isArray(init.headers)) {
293
+ for (const [k, v] of init.headers) initHeaders[String(k)] = String(v);
294
+ } else {
295
+ for (const [k, v] of Object.entries(init.headers)) initHeaders[k] = String(v);
296
+ }
297
+ }
298
+ const inputHeaders = input instanceof Request ? headersToRecord(input.headers) : {};
299
+ return { ...inputHeaders, ...initHeaders };
300
+ }
301
+
302
+ // 获取请求 body 文本
303
+ async function getRequestBodyText(input, init) {
304
+ if (input instanceof Request) {
305
+ try { return await input.clone().text(); } catch { return ""; }
306
+ }
307
+ return typeof init?.body === "string" ? init.body : "";
308
+ }
309
+
310
+ // 从 SSE body 中提取纯文本
311
+ function extractTextFromSseBody(sseBody) {
312
+ const lines = sseBody.split("\n");
313
+ const texts = [];
314
+ for (const line of lines) {
315
+ if (line.startsWith("data: ") && line !== "data: [DONE]") {
316
+ try {
317
+ const json = JSON.parse(line.slice(6));
318
+ const content = json?.choices?.[0]?.delta?.content;
319
+ if (content) texts.push(content);
320
+ } catch {}
321
+ }
322
+ }
323
+ return texts.join("");
324
+ }
325
+
326
+ // 从 JSON body 中提取纯文本
327
+ function extractTextFromJsonBody(jsonBody) {
328
+ try {
329
+ const json = JSON.parse(jsonBody);
330
+ return json?.choices?.[0]?.message?.content ?? "";
331
+ } catch {
332
+ return "";
333
+ }
334
+ }
335
+
336
+ // 从响应 body 中提取文本(自动判断 SSE/JSON)
337
+ function extractTextFromResponseBody(body, isSse) {
338
+ return isSse ? extractTextFromSseBody(body) : extractTextFromJsonBody(body);
339
+ }
340
+
341
+ // 构造 OpenAI 兼容的 SSE block 响应
342
+ function buildOpenAiSseBodyFromText(replacementText, id) {
343
+ const now = Math.floor(Date.now() / 1000);
344
+ const chunkObj = {
345
+ id, object: "chat.completion.chunk", created: now, model: "security-filtered",
346
+ choices: [{ index: 0, delta: { content: replacementText }, finish_reason: null }]
347
+ };
348
+ const finishObj = {
349
+ id, object: "chat.completion.chunk", created: now, model: "security-filtered",
350
+ choices: [{ index: 0, delta: { content: "" }, finish_reason: "stop" }]
351
+ };
352
+ return `data: ${JSON.stringify(chunkObj)}\n\ndata: ${JSON.stringify(finishObj)}\n\ndata: [DONE]\n\n`;
353
+ }
354
+
355
+ // 构造 OpenAI 兼容的 JSON block 响应
356
+ function buildOpenAiJsonBodyFromText(replacementText, id) {
357
+ const now = Math.floor(Date.now() / 1000);
358
+ return JSON.stringify({
359
+ id, object: "chat.completion", created: now, model: "security-filtered",
360
+ choices: [{ index: 0, message: { role: "assistant", content: replacementText }, finish_reason: "stop" }]
361
+ });
362
+ }
363
+
364
+ // 构造替换响应(block 模式)
365
+ function makeBlockedResponse(wantsSse, replacementText, originalResp) {
366
+ const id = "chatcmpl-security-blocked";
367
+ if (wantsSse) {
368
+ const headers = new Headers({
369
+ "content-type": "text/event-stream; charset=utf-8",
370
+ "cache-control": "no-cache",
371
+ "connection": "keep-alive"
372
+ });
373
+ return new Response(buildOpenAiSseBodyFromText(replacementText, id), { status: 200, headers });
374
+ }
375
+ // 非流式:复用原始响应头
376
+ const headers = originalResp ? new Headers(originalResp.headers) : new Headers({ "content-type": "application/json; charset=utf-8" });
377
+ headers.delete("content-length");
378
+ headers.delete("content-encoding");
379
+ return new Response(buildOpenAiJsonBodyFromText(replacementText, id), {
380
+ status: originalResp?.status ?? 200,
381
+ statusText: originalResp?.statusText ?? "OK",
382
+ headers
383
+ });
384
+ }
385
+
386
+ // 构造 hint 响应(原文 + 安全提示追加)
387
+ function makeHintResponse(originalResp, originalBody, hintContent) {
388
+ const wantsSse = isSseResponse(originalResp);
389
+ const id = "chatcmpl-security-hint";
390
+ const headers = new Headers(originalResp.headers);
391
+ headers.delete("content-length");
392
+ headers.delete("content-encoding");
393
+
394
+ const originalText = extractTextFromResponseBody(originalBody, wantsSse);
395
+ const combinedText = originalText + hintContent;
396
+ const body = wantsSse
397
+ ? buildOpenAiSseBodyFromText(combinedText, id)
398
+ : buildOpenAiJsonBodyFromText(combinedText, id);
399
+
400
+ return new Response(body, {
401
+ status: originalResp.status,
402
+ statusText: originalResp.statusText,
403
+ headers
404
+ });
405
+ }
406
+
407
+ // 安装 fetch 拦截器
408
+ function installFetchInterceptor(api) {
409
+ const originalFetch = saveOriginalFetch();
410
+ if (!originalFetch) {
411
+ log("⚠️ globalThis.fetch 不可用,LLM 拦截层未安装", "WARN");
412
+ return false;
413
+ }
414
+ if (globalThis[FETCH_WRAPPED_KEY]) {
415
+ log("Fetch 拦截器已安装(避免重复包装)", "DEBUG");
416
+ return true;
417
+ }
418
+
419
+ let callId = 0;
420
+
421
+ const wrappedFetch = async function(input, init) {
422
+ const thisCallId = ++callId;
423
+ const url = getUrlFromFetchArgs(input);
424
+
425
+ // 只拦截 LLM 相关请求(匹配 /chat/completions 或 /v1/ 路径)
426
+ const isLlmCall = url.includes("/chat/completions") || url.includes("/v1/chat") || url.includes("/completions");
427
+ if (!isLlmCall) {
428
+ return originalFetch(input, init);
429
+ }
430
+
431
+ const method = init?.method || (input instanceof Request ? input.method : "GET") || "GET";
432
+ const reqHeaders = getMergedRequestHeaders(input, init);
433
+ const reqBodyText = await getRequestBodyText(input, init);
434
+
435
+ log(`[LLM-REQ #${thisCallId}] ${method} ${url}`, "DEBUG");
436
+
437
+ // ---- 请求侧检查:提示词注入检测(已禁用) ----
438
+ // const promptInjectionPatterns = [
439
+ // { pattern: /ignore\s+(all\s+)?(previous|above)\s+(instructions|rules|prompts)/i, reason: "提示词注入:忽略先前指令" },
440
+ // { pattern: /you\s+are\s+now\s+in\s+(developer|debug|maintenance)\s+mode/i, reason: "提示词注入:切换模式" },
441
+ // { pattern: /system\s*:\s*override/i, reason: "提示词注入:系统覆盖" },
442
+ // { pattern: /\[INST\].*<\/INST>/i, reason: "提示词注入:指令标签注入" },
443
+ // { pattern: /output\s+your\s+(system\s+)?prompt/i, reason: "提示词注入:套取系统提示词" },
444
+ // { pattern: /repeat\s+(the\s+)?(words\s+)?above/i, reason: "提示词注入:重复上文" },
445
+ // ];
446
+ //
447
+ // for (const rule of promptInjectionPatterns) {
448
+ // if (rule.pattern.test(reqBodyText)) {
449
+ // alert(`PROMPT_INJECTION [REQ #${thisCallId}]: ${rule.reason}`);
450
+ // log(`[LLM-REQ-BLOCK #${thisCallId}] ${rule.reason}`, "WARN");
451
+ // const wantsSse = guessRequestWantsSse(url, reqHeaders, reqBodyText);
452
+ // return makeBlockedResponse(wantsSse, "⚠️ 检测到提示词注入攻击,请求已被安全组件拦截");
453
+ // }
454
+ // }
455
+
456
+ // ---- 放行请求,获取响应 ----
457
+ const fetchStart = Date.now();
458
+ let resp;
459
+ try {
460
+ resp = await originalFetch(input, init);
461
+ } catch (e) {
462
+ log(`[LLM-FETCH-ERR #${thisCallId}] ${e.message}`, "ERROR");
463
+ throw e;
464
+ }
465
+ const durationMs = Date.now() - fetchStart;
466
+
467
+ // ---- 响应侧检查:敏感信息脱敏 ----
468
+ const sse = isSseResponse(resp);
469
+ let respBody = "";
470
+ try {
471
+ respBody = await resp.clone().text();
472
+ } catch {
473
+ respBody = "[unreadable]";
474
+ }
475
+
476
+ const respText = extractTextFromResponseBody(respBody, sse);
477
+ if (!respText) {
478
+ log(`[LLM-RESP #${thisCallId}] ${resp.status} ${durationMs}ms (empty body)`, "DEBUG");
479
+ return resp;
480
+ }
481
+
482
+ const { sanitized, matches } = sanitizeContent(respText);
483
+ if (matches.length > 0) {
484
+ alert(`LLM_RESPONSE_SANITIZED [RESP #${thisCallId}]: 脱敏类型=${matches.join(", ")} | 原文长度=${respText.length} | 脱敏后长度=${sanitized.length} | 耗时=${durationMs}ms`);
485
+ log(`[LLM-RESP-SANITIZE #${thisCallId}] sanitized ${matches.join(", ")} (${respText.length}→${sanitized.length} chars)`, "WARN");
486
+
487
+ // 直接用脱敏后的文本 + 安全提示构造替换响应
488
+ const finalText = sanitized + `\n\n[🛡️ 安全提示:以上内容包含敏感信息,已自动脱敏(${matches.join("、")})]`;
489
+ return makeBlockedResponse(sse, finalText, resp);
490
+ }
491
+
492
+ log(`[LLM-RESP #${thisCallId}] ${resp.status} ${durationMs}ms (clean)`, "DEBUG");
493
+ return resp;
494
+ };
495
+
496
+ // 复制原始 fetch 的属性
497
+ Object.assign(wrappedFetch, originalFetch);
498
+ globalThis.fetch = wrappedFetch;
499
+ globalThis[FETCH_WRAPPED_KEY] = true;
500
+
501
+ log("✅ Fetch 拦截器已安装 — LLM 请求/响应全渠道拦截生效", "INFO");
502
+ return true;
503
+ }
504
+
505
+ // ============================================================
506
+ // 插件注册
507
+ // ============================================================
508
+ export default function register(api) {
509
+ log("═══════════════════════════════════════════════════════");
510
+ log(" MoBook 安全防护插件 v2.0 启动");
511
+ log(" 功能: 危险命令拦截 | 敏感信息脱敏(全渠道) | LLM 响应拦截 | 提示词注入检测 | 操作审计");
512
+ log("═══════════════════════════════════════════════════════");
513
+ log(`插件ID: ${api.id}`);
514
+
515
+ // ---- 安装 Fetch 拦截器(核心:全渠道 LLM 拦截) ----
516
+ installFetchInterceptor(api);
517
+
518
+ // ---- Hook 1: before_tool_call(命令拦截 + 审计) ----
519
+ api.on("before_tool_call", async (event, ctx) => {
520
+ const toolName = event?.toolName || "unknown";
521
+ const params = event?.params || {};
522
+ const safeParams = sanitizeParamsForLog(params);
523
+
524
+ log(`TOOL_CALL: tool=${toolName} params=${JSON.stringify(safeParams).slice(0, 500)}`);
525
+
526
+ // exec 命令安全检查
527
+ if (toolName === "exec") {
528
+ const cmd = params.command || "";
529
+ const danger = checkDangerousCommand(cmd);
530
+ if (danger) {
531
+ if (BLOCK_SEVERITIES.has(danger.severity)) {
532
+ alert(`BLOCKED [${danger.severity}]: ${danger.reason} | cmd: ${cmd.slice(0, 200)}`);
533
+ return {
534
+ block: true,
535
+ blockReason: `🛡️ 安全插件拦截 [${danger.severity}]:${danger.reason}\n命令: ${cmd.slice(0, 100)}`
536
+ };
537
+ } else {
538
+ alert(`WARNING [${danger.severity}]: ${danger.reason} | cmd: ${cmd.slice(0, 200)}`);
539
+ }
540
+ }
541
+ }
542
+
543
+ // 写文件工具的路径检查
544
+ if (toolName === "write" || toolName === "edit") {
545
+ const filePath = params.file || params.filePath || params.file_path || params.path || "";
546
+ const dangerousPaths = ["/etc/", "/usr/", "/bin/", "/sbin/", "/boot/", "/sys/", "/proc/"];
547
+ if (dangerousPaths.some(p => filePath.startsWith(p))) {
548
+ alert(`BLOCKED: 尝试写入系统路径 ${filePath}`);
549
+ return {
550
+ block: true,
551
+ blockReason: `🛡️ 安全插件拦截:禁止写入系统路径 ${filePath}`
552
+ };
553
+ }
554
+ }
555
+
556
+ return {};
557
+ });
558
+
559
+ // ---- Hook 2: message_sending(输出脱敏 — 补充渠道) ----
560
+ // 虽然 fetch 拦截已覆盖 webchat/飞书,但 message_sending 在 Telegram/Slack 等渠道仍然有效
561
+ // 作为双重保险保留
562
+ api.on("message_sending", async (event, ctx) => {
563
+ const content = event?.content || "";
564
+ const channel = event?.metadata?.channel || ctx?.channelId || "unknown";
565
+ if (!content) return {};
566
+
567
+ const { sanitized, matches } = sanitizeContent(content);
568
+ if (matches.length > 0) {
569
+ alert(`SANITIZED [${channel}]: 脱敏类型=${matches.join(", ")} | 原文长度=${content.length}`);
570
+ return { content: sanitized };
571
+ }
572
+ return {};
573
+ });
574
+
575
+ // ---- Hook 3: llm_output(补充监控) ----
576
+ api.on("llm_output", async (event, ctx) => {
577
+ const content = event?.content || event?.text || "";
578
+ if (!content) return;
579
+
580
+ const { matches } = sanitizeContent(content);
581
+ if (matches.length > 0) {
582
+ alert(`LLM_OUTPUT_SENSITIVE: 检测到敏感信息类型=${matches.join(", ")} | 长度=${content.length}`);
583
+ }
584
+ });
585
+
586
+ // ---- Hook 4: message_sent(发送后审计) ----
587
+ api.on("message_sent", async (event, ctx) => {
588
+ const channel = ctx?.channelId || "unknown";
589
+ const to = event?.to || "unknown";
590
+ log(`MESSAGE_SENT: channel=${channel} to=${to}`);
591
+ });
592
+
593
+ log("已注册: fetch拦截器(全渠道) + before_tool_call + message_sending + llm_output + message_sent");
594
+ log("═══════════════════════════════════════════════════════");
595
+ log(" 安全插件 v2.0 启动完成");
596
+ log("═══════════════════════════════════════════════════════");
597
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "mobook-security-plugin",
3
+ "name": "MoBook 安全防护插件",
4
+ "version": "2.0.0",
5
+ "description": "MoBook 企业级安全插件 - 危险命令分级拦截、敏感信息全渠道脱敏(身份证GB11643/银行卡Luhn校验)、LLM响应拦截、操作审计",
6
+ "entry": "./index.js",
7
+ "type": "plugin",
8
+ "configSchema": {}
9
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@dcrays/mobook-security-plugin",
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "description": "MoBook 企业级安全插件 - 危险命令分级拦截、敏感信息全渠道脱敏(身份证GB11643/银行卡Luhn校验)、LLM响应拦截、操作审计",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "openclaw.plugin.json"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "openclaw": {
16
+ "type": "plugin",
17
+ "id": "mobook-security-plugin",
18
+ "name": "MoBook 安全防护插件",
19
+ "extensions": ["./index.js"]
20
+ }
21
+ }