#!/usr/bin/env node /** * Rule — Semgrep on security-edit. * * PreToolUse Bash hook. When the controller invokes `git commit` and the staged * diff includes auth/billing/CSV/webhook files but Semgrep has not been run in * this session, block with remediation instructions. * * Three escape hatches: * 1. Run Semgrep first via Bash (`npm run sast`, `semgrep ...`). * 2. Write semgrep-skip: on a line in the assistant text. * 3. User prompt contains a global override phrase (vocab-driven). * * Spec: self-retrospect 28.05 habit #4. brain-retro #9 + retro-7 background. */ import { execFileSync } from 'child_process'; import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastAssistantText, sessionToolUses, findOverride, logOverride, exitDecision, } from './enforce-hook-helpers.mjs'; const RULE_KEY = 'semgrep-security'; const GIT_COMMIT_RE = /^\s*git\s+commit\b/; const SEMGREP_SKIP_RE = /^semgrep-skip:\s*\S+/m; const SEMGREP_CMD_RE = /\b(semgrep\b|composer\s+sast\b|npm\s+run\s+sast\b)/i; const SECURITY_PATH_PATTERNS = [ /(?:^|\/)(?:Auth|Authenticate|Authenticated|Authorization|Authorize)\b/i, /Billing/i, /Ledger/i, /(?:Csv|CSV)/i, /(?:^|\/)Imports\b/i, /Webhook/i, ]; export function isSecurityRelevantPath(path) { if (!path || typeof path !== 'string') return false; const norm = path.replace(/\\/g, '/'); for (const re of SECURITY_PATH_PATTERNS) { if (re.test(norm)) return true; } return false; } export function extractStagedFiles(stdout) { if (!stdout || typeof stdout !== 'string') return []; return stdout.split('\n').map((s) => s.trim()).filter(Boolean); } export function sessionRanSemgrep(toolUses) { if (!Array.isArray(toolUses)) return false; for (const u of toolUses) { if (!u || u.name !== 'Bash') continue; const cmd = String((u.input && u.input.command) || ''); if (SEMGREP_CMD_RE.test(cmd)) return true; } return false; } export function decide({ command, stagedFiles, semgrepRan, assistantText, override }) { // Step 1: only act on git commit invocations. if (typeof command !== 'string' || !GIT_COMMIT_RE.test(command)) return { block: false }; // Step 2: global override -> pass. if (override) return { block: false }; // Step 3: identify security-relevant staged files. const security = (Array.isArray(stagedFiles) ? stagedFiles : []).filter(isSecurityRelevantPath); if (security.length === 0) return { block: false }; // Step 4: Semgrep already ran this session -> pass. if (semgrepRan) return { block: false }; // Step 5: inline semgrep-skip with non-empty reason -> pass. if (typeof assistantText === 'string' && SEMGREP_SKIP_RE.test(assistantText)) return { block: false }; // Step 6: block. const list = security.slice(0, 5).map((p) => ' - ' + p).join('\n'); const extra = security.length > 5 ? ' ... (+' + (security.length - 5) + ' ещё)\n' : ''; const message = [ '[enforce-semgrep-security] В коммите есть ' + security.length + ' файл(ов) с security-влиянием (auth/billing/CSV/webhook):', list + (extra ? '\n' + extra : ''), 'но Semgrep не запускался в этой сессии (self-retrospect 28.05 привычка #4).', 'Сделай ОДНО из трёх:', ' 1. Запусти Semgrep на diff: `npm run sast` (или `semgrep scan --config p/php app/`).', ' 2. Добавь строку semgrep-skip: <одна строка причины> в свой ответ.', ' 3. Попроси у пользователя глобальный override (без скилов / direct ok / срочно / быстрый коммит / recovery / memory dump / ремонт инфраструктуры).', ].join('\n'); return { block: true, message }; } function readStagedFilesSafe() { try { const out = execFileSync('git', ['diff', '--cached', '--name-only'], { encoding: 'utf-8' }); return extractStagedFiles(out); } catch { return []; } } async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); if (event.tool_name !== 'Bash') { exitDecision({ block: false }); return; } const command = String((event.tool_input && event.tool_input.command) || ''); if (!GIT_COMMIT_RE.test(command)) { exitDecision({ block: false }); return; } const transcript = readTranscript(event.transcript_path); const userPrompt = lastUserPromptText(transcript); const assistantText = lastAssistantText(transcript); const sessionUses = sessionToolUses(transcript); const override = findOverride(userPrompt, RULE_KEY); if (override) logOverride(RULE_KEY, override, event.session_id); const stagedFiles = readStagedFilesSafe(); const semgrepRan = sessionRanSemgrep(sessionUses); exitDecision(decide({ command, stagedFiles, semgrepRan, assistantText, override })); } catch { exitDecision({ block: false }); } } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-semgrep-security.mjs'); if (isCli) main();