bookworm-smart-assistant/hooks/integrity-check.js

267 lines
10 KiB
JavaScript
Raw Normal View History

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