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

254 lines
9.8 KiB
JavaScript
Raw Permalink Normal View History

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