Files
portal/tools/enforce-semgrep-security.mjs
T

136 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: <non-empty reason> 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();