HuggingClaw-MissionControl / src /lib /__tests__ /security-events.test.ts
nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
Raw
History Blame Contribute Delete
6.84 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockRun = vi.fn((): any => ({ lastInsertRowid: 1, changes: 1 }))
const mockGet = vi.fn((): any => ({
auth_failures: 1,
injection_attempts: 0,
rate_limit_hits: 0,
secret_exposures: 0,
successful_tasks: 5,
failed_tasks: 0,
trust_score: 0.95,
}))
const mockPrepare = vi.fn(() => ({ run: mockRun, get: mockGet, all: vi.fn(() => []) }))
vi.mock('@/lib/db', () => ({
getDatabase: () => ({ prepare: mockPrepare }),
}))
vi.mock('@/lib/event-bus', () => ({
eventBus: { broadcast: vi.fn() },
}))
vi.mock('@/lib/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}))
import { logSecurityEvent, updateAgentTrustScore, getSecurityPosture } from '@/lib/security-events'
describe('logSecurityEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRun.mockReturnValue({ lastInsertRowid: 42, changes: 1 })
})
it('inserts an event into the database', () => {
const id = logSecurityEvent({
event_type: 'auth_failure',
severity: 'warning',
source: 'auth',
detail: 'test detail',
})
expect(mockPrepare).toHaveBeenCalled()
expect(mockRun).toHaveBeenCalledWith(
'auth_failure', 'warning', 'auth', null, 'test detail', null, 1, 1
)
expect(id).toBe(42)
})
it('defaults severity to info when not provided', () => {
logSecurityEvent({ event_type: 'test_event' })
expect(mockRun).toHaveBeenCalledWith(
'test_event', 'info', null, null, null, null, 1, 1
)
})
it('uses provided workspace_id and tenant_id', () => {
logSecurityEvent({
event_type: 'test_event',
severity: 'critical',
workspace_id: 5,
tenant_id: 3,
})
expect(mockRun).toHaveBeenCalledWith(
'test_event', 'critical', null, null, null, null, 5, 3
)
})
it('broadcasts via event bus', async () => {
const { eventBus } = await import('@/lib/event-bus')
logSecurityEvent({ event_type: 'injection_attempt', severity: 'critical' })
expect(eventBus.broadcast).toHaveBeenCalledWith(
'security.event',
expect.objectContaining({ event_type: 'injection_attempt', severity: 'critical' })
)
})
})
describe('updateAgentTrustScore', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockReturnValue({
auth_failures: 1,
injection_attempts: 0,
rate_limit_hits: 0,
secret_exposures: 0,
successful_tasks: 5,
failed_tasks: 0,
trust_score: 0.95,
})
})
it('creates a row if one does not exist (INSERT OR IGNORE)', () => {
updateAgentTrustScore('test-agent', 'auth.failure', 1)
// First call: INSERT OR IGNORE, second: UPDATE counter, third: SELECT, fourth: UPDATE score
expect(mockPrepare).toHaveBeenCalled()
expect(mockRun).toHaveBeenCalled()
})
it('recalculates trust score clamped between 0 and 1', () => {
mockGet.mockReturnValue({
auth_failures: 20,
injection_attempts: 10,
rate_limit_hits: 5,
secret_exposures: 3,
successful_tasks: 0,
failed_tasks: 0,
trust_score: 0,
})
updateAgentTrustScore('bad-agent', 'injection.attempt', 1)
// Score would go negative, should be clamped to 0
const calls = mockRun.mock.calls as any[][]
const lastCall = calls[calls.length - 1]
if (typeof lastCall[0] === 'number') {
expect(lastCall[0]).toBeGreaterThanOrEqual(0)
expect(lastCall[0]).toBeLessThanOrEqual(1)
}
})
})
describe('getSecurityPosture', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns expected posture shape', () => {
mockGet
.mockReturnValueOnce({ total: 10, critical: 2, warning: 5 })
.mockReturnValueOnce({ count: 3 })
.mockReturnValueOnce({ avg_trust: 0.85 })
const posture = getSecurityPosture(1)
expect(posture).toHaveProperty('score')
expect(posture).toHaveProperty('totalEvents')
expect(posture).toHaveProperty('criticalEvents')
expect(posture).toHaveProperty('warningEvents')
expect(posture).toHaveProperty('avgTrustScore')
expect(posture).toHaveProperty('recentIncidents')
expect(typeof posture.score).toBe('number')
expect(posture.score).toBeGreaterThanOrEqual(0)
expect(posture.score).toBeLessThanOrEqual(100)
})
it('deducts points for critical and warning events', () => {
mockGet
.mockReturnValueOnce({ total: 5, critical: 5, warning: 0 })
.mockReturnValueOnce({ count: 5 })
.mockReturnValueOnce({ avg_trust: 1.0 })
const posture = getSecurityPosture(1)
expect(posture.score).toBeLessThan(100)
})
it('returns score of 100 with no events', () => {
mockGet
.mockReturnValueOnce({ total: 0, critical: 0, warning: 0 })
.mockReturnValueOnce({ count: 0 })
.mockReturnValueOnce({ avg_trust: 1.0 })
const posture = getSecurityPosture(1)
expect(posture.score).toBe(100)
})
})
describe('injection guard new rules', () => {
let scanForInjection: typeof import('@/lib/injection-guard').scanForInjection
beforeEach(async () => {
const mod = await import('@/lib/injection-guard')
scanForInjection = mod.scanForInjection
})
it('detects SSRF targeting metadata endpoint', () => {
const report = scanForInjection('curl http://169.254.169.254/latest/meta-data/', { context: 'shell' })
expect(report.safe).toBe(false)
expect(report.matches.some(m => m.rule === 'cmd-ssrf')).toBe(true)
})
it('detects SSRF targeting localhost', () => {
const report = scanForInjection('wget http://localhost:8080/admin', { context: 'shell' })
expect(report.safe).toBe(false)
expect(report.matches.some(m => m.rule === 'cmd-ssrf')).toBe(true)
})
it('detects template injection (Jinja2)', () => {
const report = scanForInjection('{{config.__class__.__init__.__globals__}}', { context: 'prompt' })
expect(report.safe).toBe(false)
expect(report.matches.some(m => m.rule === 'cmd-template-injection')).toBe(true)
})
it('detects SQL injection (UNION SELECT)', () => {
const report = scanForInjection("' UNION SELECT * FROM users --", { context: 'shell' })
expect(report.safe).toBe(false)
expect(report.matches.some(m => m.rule === 'cmd-sql-injection')).toBe(true)
})
it('detects SQL injection (OR 1=1)', () => {
const report = scanForInjection("' OR 1=1 --", { context: 'shell' })
expect(report.safe).toBe(false)
expect(report.matches.some(m => m.rule === 'cmd-sql-injection')).toBe(true)
})
it('does not false-positive on normal SQL mentions', () => {
const report = scanForInjection('SELECT name FROM products WHERE id = 5', { context: 'shell' })
// This should not trigger because it lacks injection markers
expect(report.matches.filter(m => m.rule === 'cmd-sql-injection')).toHaveLength(0)
})
})