/** * 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', '') [/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(); } });