#!/usr/bin/env node /** * PreToolUse Hook: 阻止写入敏感文件 * 匹配器: Write|Edit * 退出码: 0=放行, 2=阻断(stderr输出JSON) * * v3.8 升级: * - 规则外部化: 从 rules/*.json 热加载 * - 安全事件日志: deny/ask 事件写入 debug/security-YYYY-MM-DD.jsonl * - 同时检查 Write 的 content 和 Edit 的 new_string 字段 * - 路径规范化(resolve .., 大小写无关匹配) * - catch 块默认 ask(fail-closed) */ const fs = require('fs'); const path = require('path'); const { logSecurityEvent } = require('./lib/security-log.js'); const { loadRules, compilePatterns, RULES_DIR } = require('./lib/rule-loader.js'); const MAX_STDIN_SIZE = 1024 * 1024; // 1MB 防止内存溢出 // CRIT-4: 规则文件丢失时使用硬编码最小安全规则,而非空数组 const FALLBACK = { 'sensitive-paths': [ { regex: '\\.env(?:\\.|$)', reason: '环境变量文件', flags: 'i' }, { regex: 'settings\\.json$', reason: '系统配置文件', flags: 'i' }, { regex: 'id_rsa|id_ed25519', reason: 'SSH 密钥', flags: 'i' }, { regex: 'credentials?\\.json$', reason: '凭证文件', flags: 'i' }, ], 'sensitive-content': [], 'sensitive-content-deny': [], }; const SENSITIVE_PATH_PATTERNS = loadRules('sensitive-paths.json', FALLBACK); const SENSITIVE_CONTENT_PATTERNS = loadRules('sensitive-content.json', FALLBACK); const SENSITIVE_CONTENT_DENY_PATTERNS = loadRules('sensitive-content-deny.json', FALLBACK); // ─── 工具函数 ────────────────────────────────────────── /** * 规范化路径: resolve 相对路径 + .. 遍历,统一为正斜杠 */ function normalizePath(filePath) { if (!filePath) return ''; let resolved = path.resolve(filePath.trim()); // P0: 解析符号链接,防止 symlink 绕过 (与 block-sensitive-reads 对齐) try { resolved = fs.realpathSync(resolved); } catch {} return resolved.replace(/\\/g, '/') .replace(/\.+$/, '') // P0: 剥离尾部点 (Windows 忽略尾部点) .replace(/::?\$DATA$/i, ''); // P0: 剥离 NTFS ADS 后缀 } /** * 提取内容字段: Write 用 content,Edit 用 new_string */ function extractContent(toolInput) { if (!toolInput) return ''; return (toolInput.content || '') + (toolInput.new_string || ''); } // ─── 主流程 ──────────────────────────────────────────── function main() { let rawInput = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { rawInput += chunk; if (rawInput.length > MAX_STDIN_SIZE) { process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: '[安全防护] 输入数据过大(>1MB),无法完成安全扫描,请用户确认操作。', })); process.exit(2); } }); process.stdin.on('end', () => { try { const input = JSON.parse(rawInput); const ti = input.tool_input || {}; const rawPath = ti.file_path || ti.filePath || ti.notebook_path || ti.path || ''; const filePath = normalizePath(rawPath); const content = extractContent(input.tool_input); // ─── F1-6: self-healer 白名单 ────────────────────── // 允许 self-healer 修改 settings.json 中已知安全的配置键 const SETTINGS_SAFE_KEYS = new Set([ 'skipDangerousModePermissionPrompt', ]); // XC11 修复: 除键名白名单外,还限制允许写入的值 const SETTINGS_SAFE_VALUES = { ["skipDangerousModePermissionPrompt"]: [false] }; function isSettingsSafeWrite(fp, c) { if (!/[\/].claude[\/]settings.json$/.test(fp)) return false; if (!c) return false; try { const parsed = JSON.parse(c); const keys = Object.keys(parsed); if (keys.length === 0) return false; if (!keys.every(k => SETTINGS_SAFE_KEYS.has(k))) return false; // XC11 修复: 校验每个键的值是否在允许列表中 for (const [k, v] of Object.entries(parsed)) { const allowed = SETTINGS_SAFE_VALUES[k]; if (allowed !== undefined && !allowed.includes(v)) return false; } return true; } catch { return false; } } // ────────────────────────────────────────────────────── // 检查文件路径 for (const { pattern, reason } of SENSITIVE_PATH_PATTERNS) { if (pattern.test(filePath)) { // F1-6: self-healer 白名单豁免 if (isSettingsSafeWrite(filePath, content)) break; logSecurityEvent('deny', 'block-sensitive-files', reason, rawPath); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'deny' }, systemMessage: `[安全防护] 阻止写入敏感文件: ${rawPath}\n原因: ${reason}\n如确需写入,请用户手动操作。` })); process.exit(2); return; } } // F6: 内容级 deny — 危险安全配置回写拦截 if (content) { for (const { pattern, reason } of SENSITIVE_CONTENT_DENY_PATTERNS) { if (pattern.test(content)) { logSecurityEvent('deny', 'block-sensitive-files', 'content-deny: ' + reason, rawPath); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'deny' }, systemMessage: `[安全防护] 阻止危险配置回写: ${reason}\n此操作已被安全策略强制禁止。` })); process.exit(2); return; } } } // 检查文件内容(仅当有内容时) if (content) { for (const { pattern, reason } of SENSITIVE_CONTENT_PATTERNS) { if (pattern.test(content)) { logSecurityEvent('ask', 'block-sensitive-files', reason, rawPath); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: `[安全警告] 文件 ${rawPath} 内容中检测到疑似 ${reason}。请用户确认是否继续写入。` })); process.exit(2); return; } } } // 安全,放行 process.exit(0); } catch (e) { // Fail-closed: 解析异常时请求用户确认而非静默放行 process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: `[安全防护] 文件写入检查遇到异常(${e.message}),请用户确认是否继续。` })); process.exit(2); } }); } /** * 可导出的文件安全检查函数 (供 dispatcher 调用) * @param {object} input - 完整的 hook stdin JSON 输入 * @returns {object|null} 检查结果,null 表示放行 * { decision: 'deny'|'ask', message: string } */ function checkFile(input) { try { const ti = input.tool_input || {}; const rawPath = ti.file_path || ti.filePath || ti.notebook_path || ti.path || ''; const filePath = normalizePath(rawPath); const content = extractContent(input.tool_input); // F1-6: self-healer 白名单 const SETTINGS_SAFE_KEYS = new Set(['skipDangerousModePermissionPrompt']); const SETTINGS_SAFE_VALUES = { ["skipDangerousModePermissionPrompt"]: [false] }; function isSettingsSafeWrite(fp, c) { if (!/[\/].claude[\/]settings.json$/.test(fp)) return false; if (!c) return false; try { const parsed = JSON.parse(c); const keys = Object.keys(parsed); if (keys.length === 0) return false; if (!keys.every(k => SETTINGS_SAFE_KEYS.has(k))) return false; for (const [k, v] of Object.entries(parsed)) { const allowed = SETTINGS_SAFE_VALUES[k]; if (allowed !== undefined && !allowed.includes(v)) return false; } return true; } catch { return false; } } // 检查文件路径 for (const { pattern, reason } of SENSITIVE_PATH_PATTERNS) { if (pattern.test(filePath)) { if (isSettingsSafeWrite(filePath, content)) break; logSecurityEvent('deny', 'block-sensitive-files', reason, rawPath); return { decision: 'deny', message: `[安全防护] 阻止写入敏感文件: ${rawPath}\n原因: ${reason}\n如确需写入,请用户手动操作。`, }; } } // F6: 内容级 deny if (content) { for (const { pattern, reason } of SENSITIVE_CONTENT_DENY_PATTERNS) { if (pattern.test(content)) { logSecurityEvent('deny', 'block-sensitive-files', 'content-deny: ' + reason, rawPath); return { decision: 'deny', message: `[安全防护] 阻止危险配置回写: ${reason}\n此操作已被安全策略强制禁止。`, }; } } } // 检查文件内容 if (content) { for (const { pattern, reason } of SENSITIVE_CONTENT_PATTERNS) { if (pattern.test(content)) { logSecurityEvent('ask', 'block-sensitive-files', reason, rawPath); return { decision: 'ask', message: `[安全警告] 文件 ${rawPath} 内容中检测到疑似 ${reason}。请用户确认是否继续写入。`, }; } } } return null; // 放行 } catch (e) { // Fail-closed: 解析异常时请求用户确认 return { decision: 'ask', message: `[安全防护] 文件写入检查遇到异常(${e.message}),请用户确认是否继续。`, }; } } // 模块导出 (供测试和 dispatcher 使用) if (typeof module !== 'undefined') { module.exports = { normalizePath, extractContent, checkFile }; } if (require.main === module) { main(); }