136 lines
5.0 KiB
JavaScript
136 lines
5.0 KiB
JavaScript
#!/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();
|