029dbe501d
Reduces full-opt-out from 11→3 categories (tdd-gate / verify-before-commit / verify-before-push). Requires_justification 'ремонт:' kept intact. Driver: brain-retro #10 trend analysis — 'ремонт инфраструктуры' fired 26 times on 2026-05-28 (vs 71 on 27.05). Used as side-effect to bypass classifier/chain/skill hooks. Per Level 1 plan. Also flips test 'global override "ремонт инфраструктуры" suppresses semgrep-security' to assert new behaviour (toBeFalsy) in tools/enforce-semgrep-security.test.mjs. Old test asserted truthy — now ремонт инфраструктуры no longer suppresses semgrep-security. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
7.2 KiB
JavaScript
181 lines
7.2 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import { decide, extractStagedFiles, isSecurityRelevantPath, sessionRanSemgrep } from './enforce-semgrep-security.mjs';
|
|
import { findOverride } from './enforce-hook-helpers.mjs';
|
|
|
|
describe('isSecurityRelevantPath', () => {
|
|
it('matches auth files', () => {
|
|
expect(isSecurityRelevantPath('app/Http/Controllers/Auth/LoginController.php')).toBe(true);
|
|
expect(isSecurityRelevantPath('app/Http/Middleware/Authenticate.php')).toBe(true);
|
|
});
|
|
it('matches billing/ledger files', () => {
|
|
expect(isSecurityRelevantPath('app/Services/BillingService.php')).toBe(true);
|
|
expect(isSecurityRelevantPath('app/Services/LedgerService.php')).toBe(true);
|
|
});
|
|
it('matches CSV import/export files', () => {
|
|
expect(isSecurityRelevantPath('app/Imports/SupplierLeadsImport.php')).toBe(true);
|
|
expect(isSecurityRelevantPath('app/Jobs/CsvReconcileJob.php')).toBe(true);
|
|
expect(isSecurityRelevantPath('app/Http/Controllers/DealCsvController.php')).toBe(true);
|
|
});
|
|
it('matches webhook files', () => {
|
|
expect(isSecurityRelevantPath('app/Http/Controllers/SupplierWebhookController.php')).toBe(true);
|
|
expect(isSecurityRelevantPath('app/Services/WebhookSignatureVerifier.php')).toBe(true);
|
|
});
|
|
it('does NOT match docs/normal files', () => {
|
|
expect(isSecurityRelevantPath('docs/superpowers/plans/2026-05-28-phase4.md')).toBe(false);
|
|
expect(isSecurityRelevantPath('memory/feedback_communication.md')).toBe(false);
|
|
expect(isSecurityRelevantPath('app/Models/Tenant.php')).toBe(false);
|
|
expect(isSecurityRelevantPath('app/Http/Controllers/HomeController.php')).toBe(false);
|
|
});
|
|
it('returns false for null/empty', () => {
|
|
expect(isSecurityRelevantPath(null)).toBe(false);
|
|
expect(isSecurityRelevantPath('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extractStagedFiles', () => {
|
|
it('parses git diff --cached --name-only output', () => {
|
|
const stdout = 'app/Services/BillingService.php\napp/Models/Deal.php\n';
|
|
expect(extractStagedFiles(stdout)).toEqual([
|
|
'app/Services/BillingService.php',
|
|
'app/Models/Deal.php',
|
|
]);
|
|
});
|
|
it('skips blank lines', () => {
|
|
expect(extractStagedFiles('a.php\n\nb.php\n')).toEqual(['a.php', 'b.php']);
|
|
});
|
|
it('returns [] for empty stdout', () => {
|
|
expect(extractStagedFiles('')).toEqual([]);
|
|
expect(extractStagedFiles(null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('sessionRanSemgrep', () => {
|
|
it('returns true when a Bash tool_use ran semgrep CLI', () => {
|
|
const sessionUses = [
|
|
{ name: 'Bash', input: { command: 'pwd' } },
|
|
{ name: 'Bash', input: { command: 'semgrep scan --config p/php' } },
|
|
];
|
|
expect(sessionRanSemgrep(sessionUses)).toBe(true);
|
|
});
|
|
it('returns true when "composer sast" ran', () => {
|
|
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast' } }])).toBe(true);
|
|
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'composer sast -- --diff' } }])).toBe(true);
|
|
});
|
|
it('returns true when "npm run sast" ran', () => {
|
|
expect(sessionRanSemgrep([{ name: 'Bash', input: { command: 'npm run sast' } }])).toBe(true);
|
|
});
|
|
it('returns false when no semgrep-like command ran', () => {
|
|
expect(sessionRanSemgrep([
|
|
{ name: 'Bash', input: { command: 'git status' } },
|
|
{ name: 'Bash', input: { command: 'npm test' } },
|
|
])).toBe(false);
|
|
});
|
|
it('returns false for empty list', () => {
|
|
expect(sessionRanSemgrep([])).toBe(false);
|
|
});
|
|
it('ignores tool_use that is not Bash', () => {
|
|
expect(sessionRanSemgrep([{ name: 'Skill', input: { skill: 'semgrep' } }])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('decide() — enforce-semgrep-security', () => {
|
|
it('passes when command is NOT a git commit', () => {
|
|
expect(decide({
|
|
command: 'git status',
|
|
stagedFiles: ['app/Services/BillingService.php'],
|
|
semgrepRan: false,
|
|
assistantText: '',
|
|
override: null,
|
|
})).toEqual({ block: false });
|
|
});
|
|
it('passes when no security-relevant files in staged', () => {
|
|
expect(decide({
|
|
command: 'git commit -m "docs: update"',
|
|
stagedFiles: ['docs/foo.md', 'memory/bar.md'],
|
|
semgrepRan: false,
|
|
assistantText: '',
|
|
override: null,
|
|
})).toEqual({ block: false });
|
|
});
|
|
it('passes when Semgrep ran this session', () => {
|
|
expect(decide({
|
|
command: 'git commit -m "feat: billing"',
|
|
stagedFiles: ['app/Services/BillingService.php'],
|
|
semgrepRan: true,
|
|
assistantText: '',
|
|
override: null,
|
|
})).toEqual({ block: false });
|
|
});
|
|
it('passes with global override', () => {
|
|
expect(decide({
|
|
command: 'git commit -m "fix"',
|
|
stagedFiles: ['app/Services/BillingService.php'],
|
|
semgrepRan: false,
|
|
assistantText: '',
|
|
override: { phrase: 'срочно' },
|
|
})).toEqual({ block: false });
|
|
});
|
|
it('passes with inline semgrep-skip with non-empty reason', () => {
|
|
expect(decide({
|
|
command: 'git commit -m "fix"',
|
|
stagedFiles: ['app/Services/BillingService.php'],
|
|
semgrepRan: false,
|
|
assistantText: 'something\nsemgrep-skip: тривиальный docstring fix\nother',
|
|
override: null,
|
|
})).toEqual({ block: false });
|
|
});
|
|
it('does NOT pass with empty semgrep-skip reason', () => {
|
|
const r = decide({
|
|
command: 'git commit -m "fix"',
|
|
stagedFiles: ['app/Services/BillingService.php'],
|
|
semgrepRan: false,
|
|
assistantText: 'semgrep-skip: ',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
});
|
|
it('blocks when commit has security file + no Semgrep + no override', () => {
|
|
const r = decide({
|
|
command: 'git commit -m "feat: billing fix"',
|
|
stagedFiles: ['app/Services/BillingService.php', 'app/Models/Deal.php'],
|
|
semgrepRan: false,
|
|
assistantText: '',
|
|
override: null,
|
|
});
|
|
expect(r.block).toBe(true);
|
|
expect(r.message).toContain('Semgrep');
|
|
expect(r.message).toContain('BillingService');
|
|
});
|
|
});
|
|
|
|
describe('override vocab coverage', () => {
|
|
it("global override \"без скилов\" suppresses semgrep-security", () => {
|
|
const o = findOverride("без скилов", 'semgrep-security');
|
|
expect(o).toBeTruthy();
|
|
});
|
|
it("global override \"direct ok\" suppresses semgrep-security", () => {
|
|
const o = findOverride("direct ok", 'semgrep-security');
|
|
expect(o).toBeTruthy();
|
|
});
|
|
it("global override \"срочно\" suppresses semgrep-security", () => {
|
|
const o = findOverride("срочно", 'semgrep-security');
|
|
expect(o).toBeTruthy();
|
|
});
|
|
it("global override \"быстрый коммит\" suppresses semgrep-security", () => {
|
|
const o = findOverride("быстрый коммит", 'semgrep-security');
|
|
expect(o).toBeTruthy();
|
|
});
|
|
it("global override \"recovery\" does NOT suppress semgrep-security (git-only scope)", () => {
|
|
const o = findOverride("recovery", 'semgrep-security');
|
|
expect(o).toBeFalsy();
|
|
});
|
|
it("global override \"memory dump\" suppresses semgrep-security", () => {
|
|
const o = findOverride("memory dump", 'semgrep-security');
|
|
expect(o).toBeTruthy();
|
|
});
|
|
it("global override \"ремонт инфраструктуры\" does NOT suppress semgrep-security (narrowed to verify-only)", () => {
|
|
const o = findOverride("ремонт инфраструктуры\nремонт: test reason", 'semgrep-security');
|
|
expect(o).toBeFalsy();
|
|
});
|
|
});
|