feat: Enhance LiveLogViewer with Security Mode and related tests
- Updated LiveLogViewer to support a new security mode, allowing for the display of security logs. - Implemented mock functions for connecting to security logs in tests. - Added tests for rendering, filtering, and displaying security log entries, including blocked requests and source filtering. - Modified Security page to utilize the new security mode in LiveLogViewer. - Updated Security page tests to reflect changes in log viewer and ensure proper rendering of security-related components. - Introduced a new script for CrowdSec startup testing, ensuring proper configuration and parser installation. - Added pre-flight checks in the CrowdSec integration script to verify successful startup and configuration.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import client from './client'
|
||||
import { getLogs, getLogContent, downloadLog, connectLiveLogs } from './logs'
|
||||
import type { LiveLogEntry } from './logs'
|
||||
import { getLogs, getLogContent, downloadLog, connectLiveLogs, connectSecurityLogs } from './logs'
|
||||
import type { LiveLogEntry, SecurityLogEntry } from './logs'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
default: {
|
||||
@@ -134,3 +134,206 @@ describe('logs api', () => {
|
||||
disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connectSecurityLogs', () => {
|
||||
it('connects to cerberus logs websocket endpoint', () => {
|
||||
const received: SecurityLogEntry[] = []
|
||||
const onOpen = vi.fn()
|
||||
|
||||
connectSecurityLogs({}, (log) => received.push(log), onOpen)
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('/api/v1/cerberus/logs/ws')
|
||||
})
|
||||
|
||||
it('passes source filter to websocket url', () => {
|
||||
connectSecurityLogs({ source: 'waf' }, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('source=waf')
|
||||
})
|
||||
|
||||
it('passes level filter to websocket url', () => {
|
||||
connectSecurityLogs({ level: 'error' }, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('level=error')
|
||||
})
|
||||
|
||||
it('passes ip filter to websocket url', () => {
|
||||
connectSecurityLogs({ ip: '192.168' }, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('ip=192.168')
|
||||
})
|
||||
|
||||
it('passes host filter to websocket url', () => {
|
||||
connectSecurityLogs({ host: 'example.com' }, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('host=example.com')
|
||||
})
|
||||
|
||||
it('passes blocked_only filter to websocket url', () => {
|
||||
connectSecurityLogs({ blocked_only: true }, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('blocked_only=true')
|
||||
})
|
||||
|
||||
it('receives and parses security log entries', () => {
|
||||
const received: SecurityLogEntry[] = []
|
||||
connectSecurityLogs({}, (log) => received.push(log))
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.open()
|
||||
|
||||
const securityLogEntry: SecurityLogEntry = {
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'info',
|
||||
logger: 'http.log.access',
|
||||
client_ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/api/test',
|
||||
status: 200,
|
||||
duration: 0.05,
|
||||
size: 1024,
|
||||
user_agent: 'TestAgent/1.0',
|
||||
host: 'example.com',
|
||||
source: 'normal',
|
||||
blocked: false,
|
||||
}
|
||||
|
||||
socket.sendMessage(JSON.stringify(securityLogEntry))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].client_ip).toBe('192.168.1.100')
|
||||
expect(received[0].source).toBe('normal')
|
||||
expect(received[0].blocked).toBe(false)
|
||||
})
|
||||
|
||||
it('receives blocked security log entries', () => {
|
||||
const received: SecurityLogEntry[] = []
|
||||
connectSecurityLogs({}, (log) => received.push(log))
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.open()
|
||||
|
||||
const blockedEntry: SecurityLogEntry = {
|
||||
timestamp: '2025-12-12T10:30:00Z',
|
||||
level: 'warn',
|
||||
logger: 'http.handlers.waf',
|
||||
client_ip: '10.0.0.1',
|
||||
method: 'POST',
|
||||
uri: '/admin',
|
||||
status: 403,
|
||||
duration: 0.001,
|
||||
size: 0,
|
||||
user_agent: 'Attack/1.0',
|
||||
host: 'example.com',
|
||||
source: 'waf',
|
||||
blocked: true,
|
||||
block_reason: 'SQL injection detected',
|
||||
}
|
||||
|
||||
socket.sendMessage(JSON.stringify(blockedEntry))
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0].blocked).toBe(true)
|
||||
expect(received[0].block_reason).toBe('SQL injection detected')
|
||||
expect(received[0].source).toBe('waf')
|
||||
})
|
||||
|
||||
it('handles onOpen callback', () => {
|
||||
const onOpen = vi.fn()
|
||||
connectSecurityLogs({}, () => {}, onOpen)
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.open()
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles onError callback', () => {
|
||||
const onError = vi.fn()
|
||||
connectSecurityLogs({}, () => {}, undefined, onError)
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
const errorEvent = new Event('error')
|
||||
socket.triggerError(errorEvent)
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(errorEvent)
|
||||
})
|
||||
|
||||
it('handles onClose callback', () => {
|
||||
const onClose = vi.fn()
|
||||
connectSecurityLogs({}, () => {}, undefined, undefined, onClose)
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.close()
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns disconnect function that closes websocket', () => {
|
||||
const disconnect = connectSecurityLogs({}, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.open()
|
||||
|
||||
expect(socket.readyState).toBe(MockWebSocket.OPEN)
|
||||
|
||||
disconnect()
|
||||
|
||||
expect(socket.readyState).toBe(MockWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('handles JSON parse errors gracefully', () => {
|
||||
const received: SecurityLogEntry[] = []
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
connectSecurityLogs({}, (log) => received.push(log))
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
socket.open()
|
||||
socket.sendMessage('invalid-json')
|
||||
|
||||
expect(received).toHaveLength(0)
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('uses wss protocol when on https', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'https:', host: 'secure.example.com', href: '' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
connectSecurityLogs({}, () => {})
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('wss://')
|
||||
expect(socket.url).toContain('secure.example.com')
|
||||
})
|
||||
|
||||
it('combines multiple filters in websocket url', () => {
|
||||
connectSecurityLogs(
|
||||
{
|
||||
source: 'waf',
|
||||
level: 'warn',
|
||||
ip: '10.0.0',
|
||||
host: 'example.com',
|
||||
blocked_only: true,
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]!
|
||||
expect(socket.url).toContain('source=waf')
|
||||
expect(socket.url).toContain('level=warn')
|
||||
expect(socket.url).toContain('ip=10.0.0')
|
||||
expect(socket.url).toContain('host=example.com')
|
||||
expect(socket.url).toContain('blocked_only=true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,39 @@ export interface LiveLogFilter {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SecurityLogEntry represents a security-relevant log entry from Cerberus.
|
||||
* This matches the backend SecurityLogEntry struct from /api/v1/cerberus/logs/ws
|
||||
*/
|
||||
export interface SecurityLogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
logger: string;
|
||||
client_ip: string;
|
||||
method: string;
|
||||
uri: string;
|
||||
status: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
user_agent: string;
|
||||
host: string;
|
||||
source: 'waf' | 'crowdsec' | 'ratelimit' | 'acl' | 'normal';
|
||||
blocked: boolean;
|
||||
block_reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters for the Cerberus security logs WebSocket endpoint.
|
||||
*/
|
||||
export interface SecurityLogFilter {
|
||||
source?: string; // Filter by security module: waf, crowdsec, ratelimit, acl, normal
|
||||
level?: string; // Filter by log level: info, warn, error
|
||||
ip?: string; // Filter by client IP (partial match)
|
||||
host?: string; // Filter by host (partial match)
|
||||
blocked_only?: boolean; // Only show blocked requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the live logs WebSocket endpoint.
|
||||
* Returns a function to close the connection.
|
||||
@@ -131,3 +164,65 @@ export const connectLiveLogs = (
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Connects to the Cerberus security logs WebSocket endpoint.
|
||||
* This streams parsed Caddy access logs with security event annotations.
|
||||
*
|
||||
* @param filters - Optional filters for source, level, IP, host, and blocked_only
|
||||
* @param onMessage - Callback for each received SecurityLogEntry
|
||||
* @param onOpen - Callback when connection is established
|
||||
* @param onError - Callback on connection error
|
||||
* @param onClose - Callback when connection closes
|
||||
* @returns A function to close the WebSocket connection
|
||||
*/
|
||||
export const connectSecurityLogs = (
|
||||
filters: SecurityLogFilter,
|
||||
onMessage: (log: SecurityLogEntry) => void,
|
||||
onOpen?: () => void,
|
||||
onError?: (error: Event) => void,
|
||||
onClose?: () => void
|
||||
): (() => void) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.source) params.append('source', filters.source);
|
||||
if (filters.level) params.append('level', filters.level);
|
||||
if (filters.ip) params.append('ip', filters.ip);
|
||||
if (filters.host) params.append('host', filters.host);
|
||||
if (filters.blocked_only) params.append('blocked_only', 'true');
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/cerberus/logs/ws?${params.toString()}`;
|
||||
|
||||
console.log('Connecting to Cerberus logs WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Cerberus logs WebSocket connection established');
|
||||
onOpen?.();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data) as SecurityLogEntry;
|
||||
onMessage(log);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse security log message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error: Event) => {
|
||||
console.error('Cerberus logs WebSocket error:', error);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
console.log('Cerberus logs WebSocket closed', { code: event.code, reason: event.reason, wasClean: event.wasClean });
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user