344 lines
16 KiB
JavaScript
344 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Bookworm v6.0 Round-2 Critical Patches — hooks/ 补丁脚本
|
||
*
|
||
* 涵盖:
|
||
* XC2 — route-compliance-gate.js: PowerShell/wmic 双执行 + driveLetter 校验
|
||
* XC3 — block-sensitive-files.js: 安全日志 detail 字段未脱敏
|
||
* XC11 — block-sensitive-files.js: isSettingsSafeWrite 只查键不查值
|
||
* XC14 — route-interceptor.js: task-notification 污染 route-state
|
||
*
|
||
* 用法: node scripts/apply-r2-critical-patches.js [--dry-run] [--patch=XC2,XC3,...]
|
||
*
|
||
* 幂等性: 每个补丁通过 sentinel 字符串检测是否已应用,已修复则跳过。
|
||
* Fail-safe: 任何单个补丁失败不影响其他补丁继续执行。
|
||
* 原子写入: 先写 .tmp 再 rename,防止写入中断导致文件损坏。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ─── 路径检测 ────────────────────────────────────────────
|
||
function detectClaudeRoot() {
|
||
const selfDir = __dirname;
|
||
if (selfDir.includes('.claude')) return selfDir.replace(/[/\\]scripts$/, '');
|
||
return (process.env.USERPROFILE || process.env.HOME || '').replace(/\\/g, '/') + '/.claude';
|
||
}
|
||
|
||
const ROOT = detectClaudeRoot();
|
||
const HOOKS_DIR = path.join(ROOT, 'hooks');
|
||
|
||
// ─── CLI 参数 ────────────────────────────────────────────
|
||
const args = process.argv.slice(2);
|
||
const DRY_RUN = args.includes('--dry-run');
|
||
const patchArg = args.find(a => a.startsWith('--patch='));
|
||
const PATCH_FILTER = patchArg
|
||
? new Set(patchArg.replace('--patch=', '').split(',').map(s => s.trim().toUpperCase()))
|
||
: null;
|
||
|
||
// ─── 结果收集 ────────────────────────────────────────────
|
||
const results = [];
|
||
|
||
/**
|
||
* 通用补丁函数:基于 sentinel 幂等检测 + 原子写入
|
||
* @param {string} id 补丁 ID
|
||
* @param {string} file 目标文件绝对路径
|
||
* @param {string} sentinel 已应用标记(存在则跳过)
|
||
* @param {Function} transform (content: string) => string,返回修改后内容
|
||
* @param {string} desc 描述
|
||
*/
|
||
function applyPatch(id, file, sentinel, transform, desc) {
|
||
if (PATCH_FILTER && !PATCH_FILTER.has(id)) {
|
||
results.push({ id, status: 'skip', reason: '未在 --patch 过滤列表中', file: path.basename(file) });
|
||
return;
|
||
}
|
||
try {
|
||
if (!fs.existsSync(file)) {
|
||
results.push({ id, status: 'error', reason: `文件不存在: ${file}`, file: path.basename(file) });
|
||
return;
|
||
}
|
||
const content = fs.readFileSync(file, 'utf8');
|
||
|
||
// 幂等检测
|
||
if (content.includes(sentinel)) {
|
||
results.push({ id, status: 'skip', reason: '已应用(幂等跳过)', file: path.basename(file) });
|
||
return;
|
||
}
|
||
|
||
const patched = transform(content);
|
||
if (patched === content) {
|
||
results.push({ id, status: 'error', reason: '目标字符串未找到(版本不匹配?)', file: path.basename(file) });
|
||
return;
|
||
}
|
||
|
||
if (DRY_RUN) {
|
||
results.push({ id, status: 'dry-run', desc, file: path.basename(file) });
|
||
return;
|
||
}
|
||
|
||
// 原子写入
|
||
const tmp = file + '.r2patch.tmp';
|
||
fs.writeFileSync(tmp, patched, 'utf8');
|
||
fs.renameSync(tmp, file);
|
||
results.push({ id, status: 'ok', desc, file: path.basename(file) });
|
||
} catch (err) {
|
||
results.push({ id, status: 'error', reason: err.message, file: path.basename(file) });
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// XC2: route-compliance-gate.js
|
||
// 问题: PowerShell 成功后 wmic 仍无条件执行(缓存 miss 时延迟翻倍)
|
||
// 修复: PowerShell 成功立即写缓存并 return;wmic 仅在 PowerShell 失败时执行
|
||
// 同时增加 driveLetter 输入校验防止命令注入
|
||
// ═══════════════════════════════════════════════════════════
|
||
applyPatch(
|
||
'XC2',
|
||
path.join(HOOKS_DIR, 'route-compliance-gate.js'),
|
||
'XC2 修复: driveLetter 输入校验', // sentinel
|
||
(content) => {
|
||
// 精确匹配现有代码块(从 driveLetter 声明到 wmic 执行结束)
|
||
const OLD = [
|
||
' // 优先 PowerShell Get-PSDrive (Windows 10/11 通用,无弃用风险)',
|
||
' try {',
|
||
' const driveLetter = drive.charAt(0);',
|
||
' const psOut = execSync(`powershell -NoProfile -Command "(Get-PSDrive ${driveLetter}).Free"`, { encoding: \'utf8\', timeout: 3000 }).trim();',
|
||
' const freeSpace = parseInt(psOut, 10);',
|
||
' if (!isNaN(freeSpace)) { result = freeSpace < THRESHOLD; }',
|
||
' } catch {}',
|
||
'',
|
||
' // Fallback: wmic (Windows 10 及旧版本)',
|
||
' const wmicOut = execSync(`wmic logicaldisk where "DeviceID=\'${drive}\'" get FreeSpace /value`, { encoding: \'utf8\', timeout: 2000 });',
|
||
' const match = wmicOut.match(/FreeSpace=(\\d+)/);',
|
||
' if (match) { result = parseInt(match[1], 10) < THRESHOLD; }',
|
||
].join('\n');
|
||
|
||
const NEW = [
|
||
' // XC2 修复: driveLetter 输入校验,防止命令注入',
|
||
' const driveLetter = drive.charAt(0);',
|
||
' if (!/^[A-Za-z]$/.test(driveLetter)) {',
|
||
' return false; // 非法驱动器字母,默认不阻断',
|
||
' }',
|
||
'',
|
||
' // 优先 PowerShell Get-PSDrive (Windows 10/11 通用,无弃用风险)',
|
||
' let psSuccess = false;',
|
||
' try {',
|
||
' const psOut = execSync(`powershell -NoProfile -Command "(Get-PSDrive ${driveLetter}).Free"`, { encoding: \'utf8\', timeout: 3000 }).trim();',
|
||
' const freeSpace = parseInt(psOut, 10);',
|
||
' if (!isNaN(freeSpace)) {',
|
||
' result = freeSpace < THRESHOLD;',
|
||
' psSuccess = true;',
|
||
' // XC2 修复: PowerShell 成功后立即写缓存并返回,不再执行 wmic',
|
||
' try {',
|
||
' require(\'fs\').writeFileSync(DISK_CACHE_FILE, JSON.stringify({ ts: Date.now(), critical: result }));',
|
||
' } catch {}',
|
||
' return result;',
|
||
' }',
|
||
' } catch {}',
|
||
'',
|
||
' // XC2 修复: Fallback wmic 仅在 PowerShell 失败时执行',
|
||
' if (!psSuccess) {',
|
||
' const wmicOut = execSync(`wmic logicaldisk where "DeviceID=\'${drive}\'" get FreeSpace /value`, { encoding: \'utf8\', timeout: 2000 });',
|
||
' const match = wmicOut.match(/FreeSpace=(\\d+)/);',
|
||
' if (match) { result = parseInt(match[1], 10) < THRESHOLD; }',
|
||
' }',
|
||
].join('\n');
|
||
|
||
return content.includes(OLD) ? content.replace(OLD, NEW) : content;
|
||
},
|
||
'XC2: PowerShell 成功后跳过 wmic + driveLetter 输入校验',
|
||
);
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// XC3-import: block-sensitive-files.js
|
||
// 注入 _sanitize 辅助函数到文件顶部 require 区域之后
|
||
// ═══════════════════════════════════════════════════════════
|
||
applyPatch(
|
||
'XC3-import',
|
||
path.join(HOOKS_DIR, 'block-sensitive-files.js'),
|
||
'// XC3 修复: 引入 sanitize 模块', // sentinel
|
||
(content) => {
|
||
const OLD = 'const fs = require(\'fs\');\nconst path = require(\'path\');';
|
||
const NEW = [
|
||
"const fs = require('fs');",
|
||
"const path = require('path');",
|
||
'',
|
||
'// XC3 修复: 引入 sanitize 模块,对安全日志中的敏感内容脱敏(防止 API Key 写入日志)',
|
||
'const _sanitize = (() => {',
|
||
' try {',
|
||
' const _root = (() => {',
|
||
' const selfDir = path.dirname(__filename);',
|
||
" if (selfDir.includes('.claude')) return selfDir.replace(/[\\/\\\\]hooks$/, '');",
|
||
" return (process.env.USERPROFILE || process.env.HOME || '').replace(/\\\\/g, '/') + '/.claude';",
|
||
' })();',
|
||
" return require(path.join(_root, 'scripts', 'sanitize.js')).sanitize;",
|
||
" } catch { return (t) => (t || ''); } // 模块缺失时透传,不阻断主流程",
|
||
'})();',
|
||
].join('\n');
|
||
return content.includes(OLD) ? content.replace(OLD, NEW) : content;
|
||
},
|
||
'XC3-import: 注入 _sanitize 辅助函数',
|
||
);
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// XC3: block-sensitive-files.js
|
||
// 对 logSecurityEvent 的 detail 字段调用 _sanitize 脱敏
|
||
// ═══════════════════════════════════════════════════════════
|
||
applyPatch(
|
||
'XC3',
|
||
path.join(HOOKS_DIR, 'block-sensitive-files.js'),
|
||
'// XC3 修复: 脱敏后写入', // sentinel
|
||
(content) => {
|
||
const OLD = " detail: (detail || '').slice(0, 200),";
|
||
const NEW = " detail: _sanitize((detail || '').slice(0, 200)), // XC3 修复: 脱敏后写入,防止 API Key 泄露到安全日志";
|
||
return content.includes(OLD) ? content.replace(OLD, NEW) : content;
|
||
},
|
||
'XC3: logSecurityEvent detail 字段脱敏',
|
||
);
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// XC11: block-sensitive-files.js
|
||
// isSettingsSafeWrite 增加值校验
|
||
// skipDangerousModePermissionPrompt 只允许设为 false
|
||
// ═══════════════════════════════════════════════════════════
|
||
applyPatch(
|
||
'XC11',
|
||
path.join(HOOKS_DIR, 'block-sensitive-files.js'),
|
||
'XC11 修复: 校验每个键的值', // sentinel
|
||
(content) => {
|
||
const OLD = [
|
||
' 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);',
|
||
' return keys.length > 0 && keys.every(k => SETTINGS_SAFE_KEYS.has(k));',
|
||
' } catch { return false; }',
|
||
' }',
|
||
].join('\n');
|
||
// 原文件实际内容(从 Read 工具确认的)
|
||
const OLD2 =
|
||
' function isSettingsSafeWrite(fp, c) {\n' +
|
||
" if (!/[\\/]\\.claude[\\/]settings\\.json$/.test(fp)) return false;\n" +
|
||
' if (!c) return false;\n' +
|
||
' try {\n' +
|
||
' const parsed = JSON.parse(c);\n' +
|
||
' const keys = Object.keys(parsed);\n' +
|
||
' return keys.length > 0 && keys.every(k => SETTINGS_SAFE_KEYS.has(k));\n' +
|
||
' } catch { return false; }\n' +
|
||
' }';
|
||
const NEW = [
|
||
' // XC11 修复: 除键名白名单外,还限制允许写入的值',
|
||
' // 攻击者不能将 skipDangerousModePermissionPrompt 设回 true',
|
||
' 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; }',
|
||
' }',
|
||
].join('\n');
|
||
if (content.includes(OLD2)) return content.replace(OLD2, NEW);
|
||
if (content.includes(OLD)) return content.replace(OLD, NEW);
|
||
return content;
|
||
},
|
||
'XC11: isSettingsSafeWrite 增加值校验,skipDangerousModePermissionPrompt 只允许 false',
|
||
);
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// XC14: route-interceptor.js
|
||
// task-notification 系统消息在 main() 中提前退出
|
||
// 注意: appendRouteLog 已有同类过滤,但 writeRouteState 调用早于它
|
||
// ═══════════════════════════════════════════════════════════
|
||
applyPatch(
|
||
'XC14',
|
||
path.join(HOOKS_DIR, 'route-interceptor.js'),
|
||
'XC14 修复: task-notification 系统消息提前退出', // sentinel
|
||
(content) => {
|
||
const OLD =
|
||
' if (!prompt || typeof prompt !== \'string\' || prompt.trim().length === 0) {\n' +
|
||
' process.exit(0);\n' +
|
||
' return;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' // v5.3: 会话首次激活横幅 (返回横幅文本或 null)';
|
||
const NEW =
|
||
' if (!prompt || typeof prompt !== \'string\' || prompt.trim().length === 0) {\n' +
|
||
' process.exit(0);\n' +
|
||
' return;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' // XC14 修复: task-notification 系统消息提前退出,不写 route-state-current.json\n' +
|
||
' // appendRouteLog 已有同类过滤,但 writeRouteState 调用早于它,state 文件会被污染\n' +
|
||
' if (prompt.includes(\'<task-notification>\')) {\n' +
|
||
' process.exit(0);\n' +
|
||
' return;\n' +
|
||
' }\n' +
|
||
'\n' +
|
||
' // v5.3: 会话首次激活横幅 (返回横幅文本或 null)';
|
||
return content.includes(OLD) ? content.replace(OLD, NEW) : content;
|
||
},
|
||
'XC14: task-notification 提前退出,不写 route-state',
|
||
);
|
||
|
||
// ─── 运行语法验证 ─────────────────────────────────────────
|
||
function verifySyntax(file) {
|
||
if (DRY_RUN) return;
|
||
try {
|
||
const { execSync } = require('child_process');
|
||
execSync(`node -c "${file}"`, { encoding: 'utf8', timeout: 5000 });
|
||
return true;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ─── 输出结果 ─────────────────────────────────────────────
|
||
console.log('\n=== Bookworm v6.0 Round-2 Patch Results ===\n');
|
||
|
||
let okCount = 0, skipCount = 0, errorCount = 0;
|
||
for (const r of results) {
|
||
const icon = r.status === 'ok' ? '✓' : r.status === 'skip' ? '–' : r.status === 'dry-run' ? '○' : '✗';
|
||
const detail = r.desc || r.reason || '';
|
||
console.log(` ${icon} [${r.id}] ${r.file}: ${r.status}${detail ? ' — ' + detail : ''}`);
|
||
if (r.status === 'ok' || r.status === 'dry-run') okCount++;
|
||
else if (r.status === 'skip') skipCount++;
|
||
else errorCount++;
|
||
}
|
||
|
||
// 语法验证(非 dry-run)
|
||
if (!DRY_RUN) {
|
||
const toVerify = [
|
||
path.join(HOOKS_DIR, 'route-compliance-gate.js'),
|
||
path.join(HOOKS_DIR, 'block-sensitive-files.js'),
|
||
path.join(HOOKS_DIR, 'route-interceptor.js'),
|
||
];
|
||
console.log('\n--- 语法验证 ---');
|
||
for (const f of toVerify) {
|
||
const ok = verifySyntax(f);
|
||
console.log(` ${ok ? '✓' : '✗'} node -c ${path.basename(f)}: ${ok ? 'OK' : 'SYNTAX ERROR'}`);
|
||
if (!ok) errorCount++;
|
||
}
|
||
}
|
||
|
||
console.log(`\n Applied: ${okCount} Skipped: ${skipCount} Errors: ${errorCount}`);
|
||
if (DRY_RUN) console.log(' (dry-run 模式,未实际写入文件)');
|
||
console.log('');
|
||
|
||
process.exit(errorCount > 0 ? 1 : 0);
|