bookworm-smart-assistant/hooks/mcp-safety-gate.js

393 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* PreToolUse Hook: MCP 高危工具安全门 (v6.1 P1)
* Matcher: mcp__supabase__execute_sql | mcp__supabase__apply_migration |
* mcp__github__create_or_update_file | mcp__github__push_files | mcp__github__delete_file |
* mcp__playwright__browser_evaluate | mcp__playwright__browser_run_code |
* mcp__chrome-devtools__evaluate_script | mcp__slack__slack_post_message
*
* 设计原则: Fail-close — 解析异常时默认 ask不放行
*
* 决策矩阵:
* deny — DROP TABLE/DATABASE, TRUNCATE, 写入 .env/.pem 等凭证文件
* ask — DELETE FROM, ALTER, migration, slack 消息, 浏览器 JS 执行
* pass — SELECT, 只读操作
*
* 退出码: 0=放行 | 2=阻断(stderr 输出 JSON)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { logSecurityEvent } = require('./lib/security-log.js');
const readStdin = require('./lib/read-stdin.js');
const MCP_CLASSIFICATION = (() => {
try {
const filePath = path.join(__dirname, 'rules', 'mcp-tool-classification.json');
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return { readonlyPatterns: [], writePatterns: [], alwaysPassPrefixes: [] };
}
})();
// ─── Supabase SQL 分析 ───────────────────────────────────────
/**
* SQL 危险度判定
* 返回 { level: 'deny'|'ask'|'pass', reason: string }
*/
function analyzeSql(sql) {
if (!sql || typeof sql !== 'string') return { level: 'pass', reason: '' };
// P1-2: SQL 注释剥离 — 跳过字符串字面量内的注释标记
// H5: 使用 PostgreSQL 标准 '' 双引号转义,而非反斜杠 \'
let stripped = '';
for (let i = 0; i < sql.length; i++) {
if (sql[i] === "'") {
let j = i + 1;
while (j < sql.length) {
if (sql[j] === "'" && sql[j + 1] === "'") { j += 2; continue; }
if (sql[j] === "'") break;
j++;
}
stripped += sql.substring(i, j + 1);
i = j;
} else if (sql[i] === '-' && sql[i+1] === '-') {
while (i < sql.length && sql[i] !== '\n') i++;
} else if (sql[i] === '/' && sql[i+1] === '*') {
// FIX: 支持嵌套注释深度追踪,防止 /* inner /* */ --line */ DROP TABLE 绕过
// 攻击向量: 第一个 */ 关闭注释后,-- 行注释吞掉剩余内容包括真正的 DROP
i += 2;
let depth = 1;
while (i < sql.length - 1) {
if (sql[i] === '/' && sql[i + 1] === '*') { depth++; i += 2; continue; }
if (sql[i] === '*' && sql[i + 1] === '/') { depth--; i += 2; if (depth === 0) break; continue; }
i++;
}
i--; // 补偿外层 for 循环的 i++
} else {
stripped += sql[i];
}
}
const upper = stripped.toUpperCase().trim();
// deny: 结构性破坏操作
if (/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|SEQUENCE|FUNCTION|TRIGGER|TYPE)\b/.test(upper)) {
return { level: 'deny', reason: 'SQL 包含 DROP 语句,拒绝执行以防止数据丢失' };
}
if (/\bTRUNCATE\b/.test(upper)) {
return { level: 'deny', reason: 'SQL 包含 TRUNCATE 语句,拒绝执行以防止全表清空' };
}
// H6: DELETE FROM 无 WHERE 子句 — 全表删除
if (/\bDELETE\s+FROM\s+\w+\s*(?:;|$)/.test(upper)) {
return { level: 'deny', reason: 'SQL DELETE 无 WHERE 子句,拒绝执行以防止全表删除' };
}
// DELETE FROM ... WHERE 1=1 / WHERE TRUE — 无条件全表删除
if (/\bDELETE\s+FROM\b.*\bWHERE\s+(?:1\s*=\s*1|TRUE|1\s*)\s*(?:;|$)/.test(upper)) {
return { level: 'deny', reason: 'SQL 包含无条件 DELETE (WHERE 1=1/TRUE),拒绝执行' };
}
// deny: 权限提升
if (/\bGRANT\s+.+\bTO\b/.test(upper)) {
return { level: 'deny', reason: 'SQL 包含 GRANT 权限授予语句,拒绝执行' };
}
// deny: 禁用 RLS 等安全配置
if (/ALTER\s+TABLE.*DISABLE\s+ROW\s+LEVEL\s+SECURITY/.test(upper)) {
return { level: 'deny', reason: 'SQL 禁用 Row Level Security拒绝执行' };
}
// ask: 有条件 DELETE
if (/\bDELETE\s+FROM\b/.test(upper)) {
return { level: 'ask', reason: 'SQL 包含 DELETE FROM需要用户确认' };
}
// ask: DDL 变更
if (/\bALTER\s+(TABLE|INDEX|SEQUENCE|VIEW|FUNCTION)\b/.test(upper)) {
return { level: 'ask', reason: 'SQL 包含 ALTER 语句,需要用户确认结构变更' };
}
// ask: 创建/修改函数
if (/\bCREATE\s+(OR\s+REPLACE\s+)?FUNCTION\b/.test(upper)) {
return { level: 'ask', reason: 'SQL 包含 CREATE FUNCTION需要用户确认' };
}
// ask: 无 WHERE 的 UPDATE
if (/\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)/.test(upper)) {
return { level: 'ask', reason: 'SQL 包含无 WHERE 子句的 UPDATE需要用户确认' };
}
return { level: 'pass', reason: '' };
}
// ─── GitHub 文件路径分析 ─────────────────────────────────────
const SENSITIVE_FILE_DENY = [
{ pattern: /(?:^|[\/\\])\.env(?:\.[^/\\]+)?$/i, reason: '目标文件为 .env 环境变量文件,拒绝写入防止凭证泄露' },
{ pattern: /(?:^|[\/\\])\.env\.(?:local|production|staging|test|prod)$/i, reason: '目标文件为生产/测试环境变量文件,拒绝写入' },
{ pattern: /\.(pem|key|p12|pfx|crt|cer|der|pkcs12)$/i, reason: '目标文件为私钥/证书文件,拒绝写入' },
{ pattern: /credentials(?:\.json)?$/i, reason: '目标文件为凭证文件,拒绝写入' },
{ pattern: /(?:^|[\/\\])\.ssh[\/\\]/i, reason: '目标路径位于 .ssh 目录,拒绝写入' },
{ pattern: /(?:aws|gcp|azure)[_-]?(?:credentials|config|secret).*\.(?:json|yaml|yml|ini|toml)$/i, reason: '目标文件为云服务凭证,拒绝写入' },
];
const SENSITIVE_FILE_ASK = [
{ pattern: /(?:^|[\/\\])settings\.json$/i, reason: '目标文件为 settings.json 配置文件,需用户确认' },
{ pattern: /(?:^|[\/\\])\.claude[\/\\]/i, reason: '目标路径位于 .claude 配置目录,需用户确认' },
{ pattern: /(?:^|[\/\\])\.github[\/\\]workflows[\/\\]/i, reason: '目标路径为 GitHub Actions 工作流文件,需用户确认' },
{ pattern: /(?:package\.json|Gemfile|requirements\.txt|go\.mod)$/i, reason: '目标文件为依赖清单,修改前需用户确认' },
{ pattern: /(?:dockerfile|docker-compose)[^/\\]*(?:\.ya?ml|\.json)?$/i, reason: '目标文件为 Docker 配置,需用户确认' },
];
function analyzeGithubPath(filePath) {
if (!filePath || typeof filePath !== 'string') return { level: 'pass', reason: '' };
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
for (const { pattern, reason } of SENSITIVE_FILE_DENY) {
if (pattern.test(normalized)) return { level: 'deny', reason };
}
for (const { pattern, reason } of SENSITIVE_FILE_ASK) {
if (pattern.test(normalized)) return { level: 'ask', reason };
}
return { level: 'pass', reason: '' };
}
// ─── 浏览器 JS 执行分析 ─────────────────────────────────────
const DANGEROUS_JS_PATTERNS = [
{ pattern: /document\.cookie/i, reason: '脚本读取 document.cookie可能泄露会话凭证' },
{ pattern: /(?:localStorage|sessionStorage)\.(?:getItem|key|length)/i, reason: '脚本读取浏览器本地存储,可能提取敏感数据' },
{ pattern: /\beval\s*\(/, reason: '脚本使用 eval() 动态执行代码' },
{ pattern: /new\s+Function\s*\(/, reason: '脚本使用 new Function() 动态创建函数' },
{ pattern: /fetch\s*\(\s*['"`]https?:\/\/(?!localhost|127\.0\.0\.1)/i, reason: '脚本包含向外部域名发送 fetch 请求' },
{ pattern: /XMLHttpRequest/i, reason: '脚本使用 XMLHttpRequest' },
{ pattern: /window\.location(?:\.href\s*=|\.replace\s*\()/i, reason: '脚本修改 window.location可能导致页面跳转' },
{ pattern: /navigator\.clipboard\.read/i, reason: '脚本读取剪贴板内容' },
{ pattern: /__proto__|constructor\.prototype/, reason: '脚本包含原型污染模式' },
];
function analyzeBrowserScript(script) {
if (!script || typeof script !== 'string') return { level: 'pass', reason: '' };
for (const { pattern, reason } of DANGEROUS_JS_PATTERNS) {
if (pattern.test(script)) return { level: 'ask', reason };
}
// 所有浏览器 JS 执行默认 ask
return { level: 'ask', reason: '浏览器脚本执行需用户确认(通用安全策略)' };
}
// ─── 决策输出 ────────────────────────────────────────────────
function outputDeny(toolName, reason, detail) {
logSecurityEvent('deny', 'mcp-safety-gate', reason, 'tool=' + toolName + ' | ' + (detail || ''));
process.stderr.write(JSON.stringify({
hookSpecificOutput: { permissionDecision: 'deny' },
systemMessage : '[mcp-safety-gate] 已拦截高危 MCP 操作\n工具: ' + toolName + '\n原因: ' + reason + '\n此操作已被安全策略强制禁止请改用更安全的方式。',
}));
process.exit(2);
}
function outputAsk(toolName, reason, detail) {
// B5: CLAUDE_SAFETY_MODE=strict 时 ask 升级为 deny (无人值守自动循环模式)
var isStrict = process.env.CLAUDE_SAFETY_MODE === 'strict';
var decision = isStrict ? 'deny' : 'ask';
var prefix = isStrict ? '[mcp-safety-gate][STRICT] 自动循环模式下已阻断' : '[mcp-safety-gate] 高风险 MCP 操作需用户确认';
logSecurityEvent(decision, 'mcp-safety-gate', reason, 'tool=' + toolName + ' | ' + (detail || '') + (isStrict ? ' | strict-escalated' : ''));
process.stderr.write(JSON.stringify({
hookSpecificOutput: { permissionDecision: decision },
systemMessage : prefix + '\n工具: ' + toolName + '\n原因: ' + reason + (isStrict ? '\n(CLAUDE_SAFETY_MODE=strict: ask 已升级为 deny)' : '\n请用户确认后继续。'),
}));
process.exit(2);
}
// ─── 自动化工具白名单(无需确认的本地自动化 MCP────────────
const AUTO_ALLOW_PREFIXES = [
'mcp__orbination__', // 桌面 UI 控制(读屏/点击/键盘)
'mcp__askui-vision__', // 视觉自动化
'mcp__playwright__', // 本地浏览器自动化
'mcp__chrome-devtools__', // Chrome DevTools调试/截图)
'mcp__context7__', // 文档查询(只读)
'mcp__sequential-thinking__', // 推理链(无副作用)
'mcp__deep-research__', // 深度研究(只读)
// [REMOVED P0-3] 高危 MCP 已从前缀白名单移除
'mcp__browser-mcp__', // 真实浏览器会话控制(含登录态)
// [REMOVED P0-3] 高危 MCP 已从前缀白名单移除
// [REMOVED P0-3] 高危 MCP 已从前缀白名单移除
];
// ─── 路由分发 ────────────────────────────────────────────────
function handleTool(toolName, toolInput) {
// [P0-3 2026-04-12] 高危子工具硬 deny 黑名单 (优先于白名单)
const HARD_DENY_PATTERNS = [
/^mcp__windows-mcp__PowerShell$/,
/^mcp__windows-mcp__Registry$/,
/^mcp__desktop-commander__start_process$/,
/^mcp__desktop-commander__write_file$/,
/^mcp__desktop-commander__edit_block$/,
/^mcp__desktop-commander__set_config_value$/,
/^mcp__desktop-commander__force_terminate$/,
/^mcp__desktop-commander__kill_process$/,
];
if (HARD_DENY_PATTERNS.some(function(p) { return p.test(toolName); })) {
return outputDeny(toolName, '高危子工具被硬黑名单禁止 (P0-3 2026-04-12)', toolName);
}
// 自动化工具白名单 — 直接放行,仅记录审计日志
if (AUTO_ALLOW_PREFIXES.some(function(p) { return toolName.startsWith(p); })) {
logSecurityEvent('pass', 'mcp-safety-gate', 'auto-allow prefix match', 'tool=' + toolName);
process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
switch (toolName) {
case 'mcp__supabase__execute_sql': {
const sql = toolInput.query || toolInput.sql || '';
const result = analyzeSql(sql);
if (result.level === 'deny') outputDeny(toolName, result.reason, sql);
if (result.level === 'ask') outputAsk(toolName, result.reason, sql);
break;
}
case 'mcp__supabase__apply_migration': {
const migrationName = toolInput.name || toolInput.migration_name || '(未知迁移)';
const sql = toolInput.query || toolInput.sql || '';
if (sql) {
const result = analyzeSql(sql);
if (result.level === 'deny') outputDeny(toolName, result.reason, sql);
}
outputAsk(toolName, '将应用数据库迁移 "' + migrationName + '",此操作不可逆,需用户确认', migrationName);
break;
}
case 'mcp__github__create_or_update_file': {
const filePath = toolInput.path || toolInput.file_path || '';
const result = analyzeGithubPath(filePath);
if (result.level === 'deny') outputDeny(toolName, result.reason, filePath);
if (result.level === 'ask') outputAsk(toolName, result.reason, filePath);
break;
}
case 'mcp__github__push_files': {
const files = toolInput.files || [];
for (const f of files) {
const fp = (typeof f === 'string' ? f : f.path || f.file_path || '');
const result = analyzeGithubPath(fp);
if (result.level === 'deny') outputDeny(toolName, result.reason, fp);
if (result.level === 'ask') outputAsk(toolName, result.reason + ' (文件: ' + fp + ')', fp);
}
if (files.length === 0) {
outputAsk(toolName, '批量推送文件操作,无法确认目标路径,需用户确认', JSON.stringify(toolInput).slice(0, 100));
}
break;
}
case 'mcp__github__delete_file': {
const filePath = toolInput.path || toolInput.file_path || '';
const result = analyzeGithubPath(filePath);
if (result.level === 'deny') outputDeny(toolName, result.reason, filePath);
outputAsk(toolName, '将删除 GitHub 文件 "' + filePath + '",此操作不可逆', filePath);
break;
}
case 'mcp__playwright__browser_evaluate':
case 'mcp__playwright__browser_run_code': {
const script = toolInput.script || toolInput.code || toolInput.expression || '';
const result = analyzeBrowserScript(script);
if (result.level === 'deny') outputDeny(toolName, result.reason, script);
if (result.level === 'ask') outputAsk(toolName, result.reason, script);
break;
}
case 'mcp__chrome-devtools__evaluate_script': {
const script = toolInput.script || toolInput.expression || '';
const result = analyzeBrowserScript(script);
if (result.level === 'deny') outputDeny(toolName, result.reason, script);
if (result.level === 'ask') outputAsk(toolName, result.reason, script);
break;
}
case 'mcp__slack__slack_post_message': {
const channel = toolInput.channel || toolInput.channel_id || '(未知频道)';
const text = toolInput.text || '';
outputAsk(
toolName,
'将向 Slack 频道 "' + channel + '" 发送消息,请确认内容和目标频道',
'channel=' + channel + ' | text_len=' + text.length,
);
break;
}
default:
// === v6.4: 新增 MCP 安全分类 ===
// windows-mcp: PowerShell/Registry/Process 需确认,其余放行
if (/^mcp__windows-mcp__(?:PowerShell|Registry|Process|FileSystem)$/.test(toolName)) {
return outputAsk(toolName, 'Windows 系统操作需用户确认 (' + toolName.split('__').pop() + ')');
}
if (/^mcp__windows-mcp__/.test(toolName)) break; // 其余 (Screenshot/Click/Type 等) 放行
// mcp-com-server: 创建/调用/设置需确认,查询放行
if (/^mcp__mcp-com-server__(?:CreateObject|InvokeMethod|SetProperty|DisposeObject)$/.test(toolName)) {
return outputAsk(toolName, 'COM 对象写操作需用户确认');
}
if (/^mcp__mcp-com-server__/.test(toolName)) break; // GetProperty/GetTypeInfo/ListActive/Query 放行
// orbination: run_sequence/open_app 需确认OCR/窗口读取/点击放行
if (/^mcp__orbination__(?:run_sequence|open_app|paste_text|set_clipboard)$/.test(toolName)) {
return outputAsk(toolName, '桌面批量操作/应用启动需用户确认');
}
if (/^mcp__orbination__/.test(toolName)) break; // OCR/click/keyboard/mouse/screenshot/window 放行
// askui-vision: vision_act 需确认 (自主执行),其余视觉操作放行
if (/^mcp__askui-vision__vision_act$/.test(toolName)) {
return outputAsk(toolName, '视觉自主操作需用户确认');
}
if (/^mcp__askui-vision__/.test(toolName)) break; // click/type/scroll/screenshot 放行
// M7: pywinauto/firecrawl/browserbase agent ask (GH-3: moved from dead code at module level)
if (/^mcp__pywinauto__/.test(toolName)) {
return outputAsk(toolName, 'Windows 桌面自动化操作需用户确认');
}
if (/^mcp__(firecrawl__firecrawl_agent|browserbase__browserbase_stagehand_agent)$/.test(toolName)) {
return outputAsk(toolName, '自主浏览器代理操作需用户确认');
}
// R3: 精确匹配工具名最后一段 action (防止恶意工具名子串匹配绕过)
const actionPart = toolName.split('__').pop() || '';
const SAFE_ACTIONS = /^(?:navigate|screenshot|snapshot|click|hover|drag|select|press|wait|resize|close|tabs|back|observe|get_url|list|locate|scroll|key|get|status)(?:_[a-z]+)*$/;
const INTERACTIVE_ACTIONS = /^(?:fill|type|extract)(?:_[a-z]+)*$/;
if (SAFE_ACTIONS.test(actionPart)) {
break; // 只读/导航操作: 安全放行
}
if (INTERACTIVE_ACTIONS.test(actionPart)) {
return outputAsk(toolName, '交互操作(填写/输入/提取)需用户确认');
}
outputAsk(toolName, '未识别的 MCP 写入请求(安全策略: 未知工具默认询问)', toolName);
}
}
// ─── 主流程 ──────────────────────────────────────────────────
function main() {
readStdin({ maxSize: 512 * 1024 }).then(input => {
const toolName = input.tool_name || '';
const ti = input.tool_input || {};
if (!toolName) { process.exit(0); return; }
handleTool(toolName, ti);
process.exit(0);
}).catch((e) => {
// fail-close → ask (strict 模式下升级为 deny)
const isStrict = process.env.CLAUDE_SAFETY_MODE === 'strict';
const decision = isStrict ? 'deny' : 'ask';
process.stderr.write(JSON.stringify({
hookSpecificOutput: { permissionDecision: decision },
systemMessage : '[mcp-safety-gate] 安全检查异常 (' + (e.message || '') + ')' + (isStrict ? '已拒绝执行' : '请用户确认是否继续执行 MCP 操作') + '。',
}));
process.exit(2);
});
}
if (typeof module !== 'undefined') {
module.exports = { analyzeSql, analyzeGithubPath, analyzeBrowserScript };
}
if (require.main === module) {
main();
}