From 91bac52e7d415f1db178c8ab694aa3f9f0ab231b Mon Sep 17 00:00:00 2001 From: Musa Khalid Date: Fri, 19 Jun 2026 18:11:07 +0100 Subject: [PATCH] feat: add elastic security integration with comprehensive tests - Add ElasticSiemProvider tests covering event forwarding and error handling - Document elastic configuration in .env.example - Support ECS-compliant event formatting for security analytics - Implement robust error handling with detailed failure logging - Add health check validation for cluster status monitoring --- .env.example | 5 + .../providers/elastic.siem-provider.spec.ts | 214 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 apps/backend/src/integrations/siem/providers/elastic.siem-provider.spec.ts diff --git a/.env.example b/.env.example index dfd6e80..8cee34e 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,10 @@ DATABASE_PASSWORD=your_password_here DATABASE_NAME=sentinel_db DATABASE_URL=postgresql://sentinel_user:your_password_here@localhost:5432/sentinel_db +# Elastic SIEM Integration +ELASTIC_URL=https://elastic.corp:9200 +ELASTIC_API_KEY=your-api-key-here +ELASTIC_INDEX=sentinel-events + # Notifications DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-url \ No newline at end of file diff --git a/apps/backend/src/integrations/siem/providers/elastic.siem-provider.spec.ts b/apps/backend/src/integrations/siem/providers/elastic.siem-provider.spec.ts new file mode 100644 index 0000000..6fd7bbb --- /dev/null +++ b/apps/backend/src/integrations/siem/providers/elastic.siem-provider.spec.ts @@ -0,0 +1,214 @@ +import axios from 'axios'; +import { ElasticSiemProvider } from './elastic.siem-provider'; +import { ElasticSiemConfig } from '../dto/siem-config.dto'; +import { SiemEvent } from '../interfaces/siem-event.interface'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const makeConfig = (overrides: Partial = {}): ElasticSiemConfig => ({ + elasticUrl: 'https://elastic.corp:9200', + apiKey: 'test-api-key', + ...overrides, +}); + +const makeEvent = (overrides: Partial = {}): SiemEvent => ({ + timestamp: '2026-06-19T10:00:00.000Z', + eventType: 'suspicious_transaction', + title: 'Suspicious Transaction Detected', + message: 'Large transaction detected from flagged address', + severity: 'high', + source: 'stellar', + ...overrides, +}); + +describe('ElasticSiemProvider', () => { + let provider: ElasticSiemProvider; + let config: ElasticSiemConfig; + + beforeEach(() => { + jest.clearAllMocks(); + config = makeConfig(); + provider = new ElasticSiemProvider(config); + }); + + it('should have provider name "elastic"', () => { + expect(provider.providerName).toBe('elastic'); + }); + + describe('forwardEvent', () => { + it('should forward event to Elasticsearch bulk endpoint', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + const event = makeEvent(); + await provider.forwardEvent(event); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://elastic.corp:9200/_bulk', + expect.any(String), + { + headers: { + Authorization: 'ApiKey test-api-key', + 'Content-Type': 'application/x-ndjson', + }, + }, + ); + }); + + it('should use custom index when provided', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + const customConfig = makeConfig({ index: 'custom-sentinel-index' }); + const customProvider = new ElasticSiemProvider(customConfig); + + await customProvider.forwardEvent(makeEvent()); + + const body = (mockedAxios.post.mock.calls[0][1] as string).split('\n'); + const indexLine = JSON.parse(body[0]); + expect(indexLine.index._index).toBe('custom-sentinel-index'); + }); + + it('should use default index when not provided', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + await provider.forwardEvent(makeEvent()); + + const body = (mockedAxios.post.mock.calls[0][1] as string).split('\n'); + const indexLine = JSON.parse(body[0]); + expect(indexLine.index._index).toBe('sentinel-events'); + }); + + it('should format event with ECS-compliant fields', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + const event = makeEvent({ severity: 'critical' }); + await provider.forwardEvent(event); + + const body = (mockedAxios.post.mock.calls[0][1] as string).split('\n'); + const doc = JSON.parse(body[1]); + + expect(doc).toMatchObject({ + '@timestamp': '2026-06-19T10:00:00.000Z', + 'event.kind': 'alert', + 'event.category': 'intrusion_detection', + 'event.type': 'suspicious_transaction', + 'event.severity': 100, + message: 'Large transaction detected from flagged address', + labels: { + title: 'Suspicious Transaction Detected', + source: 'stellar', + severity: 'critical', + }, + }); + }); + + it('should map severity levels correctly', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + const severities: Array = ['low', 'medium', 'high', 'critical']; + const expectedValues = [25, 50, 75, 100]; + + for (let i = 0; i < severities.length; i++) { + mockedAxios.post.mockClear(); + const event = makeEvent({ severity: severities[i] }); + await provider.forwardEvent(event); + + const body = (mockedAxios.post.mock.calls[0][1] as string).split('\n'); + const doc = JSON.parse(body[1]); + expect(doc['event.severity']).toBe(expectedValues[i]); + } + }); + + it('should include metadata in the event document', async () => { + mockedAxios.post.mockResolvedValue({ data: { items: [] } }); + + const event = makeEvent({ + metadata: { + address: '0x1234', + amount: 1000000, + riskScore: 0.85, + }, + }); + await provider.forwardEvent(event); + + const body = (mockedAxios.post.mock.calls[0][1] as string).split('\n'); + const doc = JSON.parse(body[1]); + + expect(doc.address).toBe('0x1234'); + expect(doc.amount).toBe(1000000); + expect(doc.riskScore).toBe(0.85); + }); + + it('should throw error when Elasticsearch request fails', async () => { + // Create an error object that mimics axios error structure + const axiosError = new Error('Request failed') as unknown as { + isAxiosError: boolean; + response: { data: { error: { reason: string } } }; + }; + axiosError.isAxiosError = true; + axiosError.response = { + data: { + error: { + reason: 'index_not_found_exception', + }, + }, + }; + mockedAxios.post.mockRejectedValue(axiosError); + + await expect(provider.forwardEvent(makeEvent())).rejects.toThrow( + 'ElasticSiemProvider.forwardEvent failed', + ); + }); + + it('should handle network errors gracefully', async () => { + mockedAxios.post.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(provider.forwardEvent(makeEvent())).rejects.toThrow('ECONNREFUSED'); + }); + }); + + describe('isHealthy', () => { + it('should return true when cluster health is green', async () => { + mockedAxios.get.mockResolvedValue({ + data: { status: 'green' }, + }); + + const health = await provider.isHealthy(); + expect(health).toBe(true); + }); + + it('should return true when cluster health is yellow', async () => { + mockedAxios.get.mockResolvedValue({ + data: { status: 'yellow' }, + }); + + const health = await provider.isHealthy(); + expect(health).toBe(true); + }); + + it('should return false when cluster health is red', async () => { + mockedAxios.get.mockResolvedValue({ + data: { status: 'red' }, + }); + + const health = await provider.isHealthy(); + expect(health).toBe(false); + }); + + it('should return false when health check fails', async () => { + mockedAxios.get.mockRejectedValue(new Error('Connection timeout')); + + const health = await provider.isHealthy(); + expect(health).toBe(false); + }); + + it('should return false when response has no status field', async () => { + mockedAxios.get.mockResolvedValue({ + data: {}, + }); + + const health = await provider.isHealthy(); + expect(health).toBe(false); + }); + }); +});