新增 Edit/Write/MultiEdit 事件攔截(原僅攔截 git commit Bash 指令), 補齊 getenv fallback 模式偵測,防止 hardcoded Token 透過工具直寫入檔案。 - .claude/hooks/commit-quality.js: 改寫為 PreToolUse JSON 格式,覆蓋 Edit/Write/MultiEdit - .claude/settings.json: 新增 Edit|Write|MultiEdit|Bash matcher 註冊 - .claude/hooks/__test__/commit-quality.test.sh: 4 case 自動化測試 - docs/guides/DISK_EXPANSION_GUIDE.md: 磁碟擴充 SOP 歸檔 - docs/p9_completion_report_*.md: P9-1 + P9-2 Sprint 完成報告 - docs/refactor/callback_prefix_proposal.md: 308 按鈕回呼前綴分析(Method C) - docs/refactor/openclaw_bot_routes_split_plan.md: 5999 行神檔拆分計畫 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
106 lines
3.7 KiB
JavaScript
106 lines
3.7 KiB
JavaScript
/**
|
||
* commit-quality.js — PreToolUse Hook
|
||
* 攔截硬編碼 Secret:
|
||
* - Bash `git commit`:掃 staged diff(保留舊行為)
|
||
* - Edit / Write / MultiEdit:掃 new_string / content / edits[].new_string
|
||
* 輸出遵循官方 PreToolUse 規格:
|
||
* permissionDecision: "allow" | "deny"
|
||
*/
|
||
const { spawnSync } = require('child_process');
|
||
|
||
const SECRET_PATTERNS = [
|
||
[/sk-[a-zA-Z0-9]{20,}/, 'OpenAI API Key'],
|
||
[/ghp_[a-zA-Z0-9]{36}/, 'GitHub PAT'],
|
||
[/AKIA[A-Z0-9]{16}/, 'AWS Access Key'],
|
||
[/AIza[a-zA-Z0-9_-]{35}/, 'Google API Key'],
|
||
[/\b\d{8,12}:[A-Za-z0-9_-]{35}\b/, 'Telegram Bot Token'],
|
||
[/TELEGRAM[_\s]*(?:BOT[_\s]*)?TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Telegram Token 環境變數'],
|
||
[/GEMINI_API_KEY\s*=\s*["']?[A-Za-z0-9_-]{20,}/, 'Gemini API Key'],
|
||
[/sk-ant-api[0-9a-zA-Z_-]{20,}/, 'Anthropic API Key'],
|
||
[/glpat-[a-zA-Z0-9_-]{20}/, 'Gitea/GitLab PAT'],
|
||
[/GITEA[_\s]*TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Gitea Token 環境變數'],
|
||
// Python fallback default: os.getenv('X', '<secret ≥30 chars>')
|
||
[/getenv\s*\(\s*[^,)]+,\s*['"][^'"\s]{30,}['"]\s*\)/, 'Python getenv fallback 含疑似機密'],
|
||
[/os\.environ\.get\s*\(\s*[^,)]+,\s*['"][^'"\s]{30,}['"]\s*\)/, 'os.environ.get fallback 含疑似機密'],
|
||
];
|
||
|
||
function scan(text, fileHint) {
|
||
if (!text) return [];
|
||
const hits = [];
|
||
for (const [pat, label] of SECRET_PATTERNS) {
|
||
const m = text.match(pat);
|
||
if (m) hits.push(`${label}${fileHint ? ` in ${fileHint}` : ''}: ${m[0].slice(0, 24)}...`);
|
||
}
|
||
// debugger 只對 JS 家族
|
||
if (fileHint && /\.(js|jsx|ts|tsx)$/.test(fileHint) && /\bdebugger\b/.test(text)) {
|
||
hits.push(`debugger statement in ${fileHint}`);
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
function allow() {
|
||
// 預設 allow:輸出官方規格 JSON
|
||
const out = {
|
||
hookSpecificOutput: {
|
||
hookEventName: 'PreToolUse',
|
||
permissionDecision: 'allow',
|
||
},
|
||
};
|
||
process.stdout.write(JSON.stringify(out));
|
||
process.exit(0);
|
||
}
|
||
|
||
function deny(reasons) {
|
||
const reason = `[commit-quality] 偵測到機密或禁用內容,已阻擋:\n- ${reasons.join('\n- ')}\n請移除後重試。`;
|
||
process.stderr.write(reason + '\n');
|
||
const out = {
|
||
hookSpecificOutput: {
|
||
hookEventName: 'PreToolUse',
|
||
permissionDecision: 'deny',
|
||
permissionDecisionReason: reason,
|
||
},
|
||
};
|
||
process.stdout.write(JSON.stringify(out));
|
||
process.exit(0);
|
||
}
|
||
|
||
let d = '';
|
||
process.stdin.on('data', c => d += c);
|
||
process.stdin.on('end', () => {
|
||
try {
|
||
const i = JSON.parse(d || '{}');
|
||
const tool = i.tool_name || '';
|
||
const ti = i.tool_input || {};
|
||
const hits = [];
|
||
|
||
if (tool === 'Bash') {
|
||
const cmd = ti.command || '';
|
||
if (!/git\s+commit/.test(cmd) || /--amend/.test(cmd)) return allow();
|
||
|
||
const r = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' });
|
||
const files = (r.stdout || '').trim().split('\n').filter(Boolean);
|
||
for (const f of files) {
|
||
if (!/\.(js|jsx|ts|tsx|py|sh|json|yaml|yml|env|toml|ini|conf)$/.test(f)) continue;
|
||
const cr = spawnSync('git', ['show', ':' + f], { encoding: 'utf8' });
|
||
hits.push(...scan(cr.stdout || '', f));
|
||
}
|
||
} else if (tool === 'Write') {
|
||
hits.push(...scan(ti.content || '', ti.file_path));
|
||
} else if (tool === 'Edit') {
|
||
hits.push(...scan(ti.new_string || '', ti.file_path));
|
||
} else if (tool === 'MultiEdit') {
|
||
const edits = Array.isArray(ti.edits) ? ti.edits : [];
|
||
for (const e of edits) hits.push(...scan(e.new_string || '', ti.file_path));
|
||
} else {
|
||
return allow();
|
||
}
|
||
|
||
if (hits.length) return deny(hits);
|
||
return allow();
|
||
} catch (e) {
|
||
process.stderr.write(`[commit-quality] hook internal error: ${e.message}\n`);
|
||
// 內部錯誤不阻擋,避免誤殺
|
||
return allow();
|
||
}
|
||
});
|