bookworm-smart-assistant/hooks/block-sensitive-files.js

254 lines
9.8 KiB
JavaScript
Raw Permalink 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: 阻止写入敏感文件
* 匹配器: 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 块默认 askfail-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 用 contentEdit 用 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();
}