254 lines
9.8 KiB
JavaScript
254 lines
9.8 KiB
JavaScript
|
|
#!/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();
|
|||
|
|
}
|