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