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

393 lines
19 KiB
JavaScript
Raw Permalink Normal View History

#!/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();
}