#!/usr/bin/env node /** * PostToolUse Hook: 钩子文件完整性校验 * 匹配器: Edit|Write * 触发: 任何文件写入后检查 hooks/*.js 是否被篡改 * 退出码: 0=通过, 2=阻断(ask, 篡改时要求用户确认) * * 机制: * - 启动时加载 hooks/checksums.json 基线 * - 计算当前所有钩子文件的 SHA256 * - 不匹配时写入 security-*.jsonl 并阻断操作 (exit(2) + ask) * - 基线不存在时静默通过 (首次部署) * * 基线生成: * node hooks/integrity-check.js --generate */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const readStdin = require('./lib/read-stdin.js'); const HOOKS_DIR = path.dirname(__filename); const CHECKSUMS_FILE = path.join(HOOKS_DIR, 'checksums.json'); // 动态检测配置根目录 function sha256(filePath) { const content = fs.readFileSync(filePath); return crypto.createHash('sha256').update(content).digest('hex'); } // 机器绑定 HMAC 密钥 (hostname + username + 固定盐) function getMachineKey() { const os = require("os"); const salt = "bookworm-integrity-v1"; const raw = os.hostname() + ":" + os.userInfo().username + ":" + salt; return crypto.createHash("sha256").update(raw).digest(); } function computeHMAC(filePath) { const content = fs.readFileSync(filePath); const key = getMachineKey(); return crypto.createHmac("sha256", key).update(content).digest("hex"); } const SIG_FILE = path.join(HOOKS_DIR, "checksums.sig"); function getHookFiles() { return fs.readdirSync(HOOKS_DIR) .filter(f => f.endsWith('.js') && f !== 'integrity-check.js') .sort(); } // 发现 hooks/lib/ 下的共享库文件 function getLibFiles() { try { const libDir = path.join(HOOKS_DIR, 'lib'); if (!fs.existsSync(libDir)) return []; return fs.readdirSync(libDir) .filter(f => f.endsWith('.js')) .sort(); } catch { return []; } } // P2: scripts/ 关键文件纳入完整性校验 // F5: 自动发现 scripts/ 下所有 .js + 关键 .json 文件 (替代硬编码列表) const AUTO_DISCOVER_SCRIPTS = true; function discoverScriptFiles() { try { const scriptsDir = path.join(require('./lib/root.js'), 'scripts'); if (!fs.existsSync(scriptsDir)) return []; return fs.readdirSync(scriptsDir) .filter(f => f.endsWith('.js') || f === 'disambiguation-rules.json' || f === 'synonyms.json') .filter(f => !f.startsWith('_')) // 排除临时补丁脚本 .sort(); } catch { return []; } } const CRITICAL_SCRIPTS = discoverScriptFiles(); function getCriticalScriptFiles(){const p=require("path"),scriptsDir=p.join(require('./lib/root.js'),"scripts");return CRITICAL_SCRIPTS.filter(f=>fs.existsSync(p.join(scriptsDir,f)));} // 解析校验文件的实际路径 (scripts/ 从 claude root, lib/ 从 hooks/lib/) function resolveFilePath(file) { if (file.startsWith("scripts/")) { return require("path").join(require('./lib/root.js'), file); } if (file.startsWith("lib/")) { return require("path").join(HOOKS_DIR, file); } return require("path").join(HOOKS_DIR, file); } const { logSecurityEvent } = require('./lib/security-log.js'); // === --generate 模式: 生成基线 === if (process.argv.includes('--generate')) { const hookFiles = getHookFiles(); const checksums = {}; for (const f of hookFiles) { checksums[f] = sha256(path.join(HOOKS_DIR, f)); } // P2: 同时校验 hooks/lib/ 共享库文件 const libDir = path.join(HOOKS_DIR, 'lib'); const libFiles = getLibFiles(); for (const lf of libFiles) { checksums["lib/" + lf] = sha256(path.join(libDir, lf)); } // P2: 同时校验 scripts/ 关键文件 const scriptsDir=require("path").join(require('./lib/root.js'),"scripts"); const scriptFiles=getCriticalScriptFiles(); for(const sf of scriptFiles){checksums["scripts/"+sf]=sha256(require("path").join(scriptsDir,sf));} // GH-2: 原子写入 (temp + rename) const _tmpChecksums = CHECKSUMS_FILE + '.tmp.' + process.pid; fs.writeFileSync(_tmpChecksums, JSON.stringify(checksums, null, 2) + '\n'); fs.renameSync(_tmpChecksums, CHECKSUMS_FILE); console.log(`checksums.json generated: ${hookFiles.length} hooks + ${libFiles.length} libs + ${scriptFiles.length} scripts`); for (const [f, hash] of Object.entries(checksums)) { console.log(` ${f}: ${hash.slice(0, 16)}...`); } // 写入 HMAC 签名 const sig = computeHMAC(CHECKSUMS_FILE); fs.writeFileSync(SIG_FILE, sig + "\n"); console.log("HMAC signature: " + sig.slice(0, 16) + "..."); // W6: 将 integrity-check.js 自身的哈希写入独立签名文件 (解决"谁监督监督者"问题) const selfHash = sha256(path.join(HOOKS_DIR, 'integrity-check.js')); const selfSigFile = path.join(HOOKS_DIR, 'integrity-check.js.self-hash'); fs.writeFileSync(selfSigFile, selfHash + "\n"); console.log("Self-hash: " + selfHash.slice(0, 16) + "..."); process.exit(0); } // === Hook 模式: 校验完整性 === function main() { readStdin({ maxSize: 1024 * 1024 }).then(input => { const toolName = input.tool_name; // 只在文件写入时触发校验 if (toolName !== 'Edit' && toolName !== 'Write') { process.exit(0); return; } // 检查是否修改了 hooks/、hooks/lib/ 或 scripts/ 目录下的文件 const filePath = (input.tool_input && (input.tool_input.file_path || input.tool_input.filePath)) || ''; const isHookFile = filePath.includes('hooks') && filePath.endsWith('.js'); const isScriptFile = filePath.includes('scripts') && (filePath.endsWith('.js') || filePath.endsWith('.json')); if (!isHookFile && !isScriptFile) { process.exit(0); return; } // R4: 基线不存在时 fail-close (防止攻击者删除 checksums.json 绕过校验) if (!fs.existsSync(CHECKSUMS_FILE)) { logSecurityEvent("alert", "integrity-check", "checksums-missing", "checksums.json not found"); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: '[integrity-check] 完整性基线文件缺失 (checksums.json)。可能被删除或尚未生成。\n请运行 node hooks/integrity-check.js --generate 重建基线。', })); process.exit(2); return; } // HMAC 签名验证 (防 checksums.json 被连带篡改) if (fs.existsSync(SIG_FILE)) { const expectedSig = fs.readFileSync(SIG_FILE, "utf8").trim(); const actualSig = computeHMAC(CHECKSUMS_FILE); if (expectedSig !== actualSig) { logSecurityEvent("alert", "integrity-check", "checksums-tampered", "HMAC mismatch"); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: "[integrity-check] checksums.json HMAC 签名不匹配!基线文件可能被篡改。请运行 node hooks/integrity-check.js --generate 重新签名。", })); process.exit(2); return; } } const baseline = JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8')); const tampered = []; for (const [file, expectedHash] of Object.entries(baseline)) { const hookPath = resolveFilePath(file); if (!fs.existsSync(hookPath)) { tampered.push({ file, issue: 'missing' }); continue; } const actualHash = sha256(hookPath); if (actualHash !== expectedHash) { tampered.push({ file, issue: 'modified', expected: expectedHash.slice(0, 16), actual: actualHash.slice(0, 16) }); } } if (tampered.length === 0) { process.exit(0); return; } // P1-7 修复: 发现篡改时阻断操作 (exit(2) + ask),而非仅告警放行 const changedFiles = tampered.map(t => `${t.file}:${t.issue}`); const detail = changedFiles.join(', '); logSecurityEvent('alert', 'integrity-check', 'hook-tamper', detail); process.stderr.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' }, systemMessage: `[完整性告警] 检测到安全钩子文件被修改: ${changedFiles.join(', ')}。请确认是否为授权修改。`, })); process.exit(2); }).catch(() => process.exit(2)); // P1-12: fail-close — 异常时要求确认 } // 模块导出 (供测试使用) if (typeof module !== 'undefined') { /** * 内联检查函数 (供 post-edit-dispatcher 委托调用) * 包含 HMAC 签名验证 — P1 修复: 之前内联版本跳过了此关键安全步骤 */ function inlineCheck(filePath) { try { const isHookFile = filePath.includes('hooks') && filePath.endsWith('.js'); const isScriptFile = filePath.includes('scripts') && (filePath.endsWith('.js') || filePath.endsWith('.json')); if (!isHookFile && !isScriptFile) return null; if (!fs.existsSync(CHECKSUMS_FILE)) return null; // HMAC 签名验证 (P1 修复: 之前内联版本缺失此步骤) if (fs.existsSync(SIG_FILE)) { const expectedSig = fs.readFileSync(SIG_FILE, 'utf8').trim(); const actualSig = computeHMAC(CHECKSUMS_FILE); if (expectedSig !== actualSig) { logSecurityEvent('alert', 'integrity-check', 'checksums-tampered', 'HMAC mismatch (via dispatcher)'); return '[integrity-check] checksums.json HMAC 签名不匹配!基线文件可能被篡改。请运行 node hooks/integrity-check.js --generate 重新签名。'; } } const baseline = JSON.parse(fs.readFileSync(CHECKSUMS_FILE, 'utf8')); const tampered = []; for (const [file, expectedHash] of Object.entries(baseline)) { const hookPath = resolveFilePath(file); if (!fs.existsSync(hookPath)) { tampered.push(file + ':missing'); continue; } const actualHash = sha256(hookPath); if (actualHash !== expectedHash) { tampered.push(file + ':modified'); } } if (tampered.length === 0) return null; logSecurityEvent('alert', 'integrity-check', 'hook-tamper', tampered.join(', ')); return `[integrity-check] Hook 文件变更: ${tampered.length} 个文件与基线不匹配 (${tampered.join(', ')})。如果是合法修改,请运行 node hooks/integrity-check.js --generate 更新基线。`; } catch (e) { return '[integrity-check] 完整性校验异常: ' + ((e && e.message) || 'unknown').slice(0, 100); } } module.exports = { sha256, getHookFiles, logSecurityEvent, inlineCheck }; } if (require.main === module) { main(); }