2026-05-28 13:40:07 +03:00
|
|
|
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();
|
|
|
|
|
});
|
2026-05-28 17:30:09 +03:00
|
|
|
it("global override \"recovery\" does NOT suppress semgrep-security (git-only scope)", () => {
|
2026-05-28 13:40:07 +03:00
|
|
|
const o = findOverride("recovery", 'semgrep-security');
|
2026-05-28 17:30:09 +03:00
|
|
|
expect(o).toBeFalsy();
|
2026-05-28 13:40:07 +03:00
|
|
|
});
|
|
|
|
|
it("global override \"memory dump\" suppresses semgrep-security", () => {
|
|
|
|
|
const o = findOverride("memory dump", 'semgrep-security');
|
|
|
|
|
expect(o).toBeTruthy();
|
|
|
|
|
});
|
2026-05-28 17:34:38 +03:00
|
|
|
it("global override \"ремонт инфраструктуры\" does NOT suppress semgrep-security (narrowed to verify-only)", () => {
|
2026-05-28 13:40:07 +03:00
|
|
|
const o = findOverride("ремонт инфраструктуры\nремонт: test reason", 'semgrep-security');
|
2026-05-28 17:34:38 +03:00
|
|
|
expect(o).toBeFalsy();
|
2026-05-28 13:40:07 +03:00
|
|
|
});
|
|
|
|
|
});
|