Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ ELASTIC_API_KEY=your-api-key-here
ELASTIC_INDEX=sentinel-events

# Notifications
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-url
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-url
# Splunk HTTP Event Collector (HEC) Integration
SPLUNK_HEC_URL=
SPLUNK_HEC_TOKEN=
SPLUNK_INDEX=main
4 changes: 4 additions & 0 deletions apps/backend/src/integrations/siem/dto/siem-config.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface SplunkSiemConfig {
hecToken: string;
/** Splunk source type tag applied to every event (default: "sentinel:security"). */
sourceType?: string;
/** Max retry attempts on transient delivery failure (default: 3). */
maxRetries?: number;
/** Base backoff delay in ms between retries, doubled each attempt (default: 500). */
retryBaseDelayMs?: number;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import axios from 'axios';
import { SplunkSiemProvider } from './splunk.siem-provider';
import { SplunkSiemConfig } from '../dto/siem-config.dto';
import { SiemEvent } from '../interfaces/siem-event.interface';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

// jest.mock('axios') auto-mocks every export, including isAxiosError, which
// would otherwise return undefined and break the provider's retry logic.
// Restore the real implementation so retryable-vs-not classification works.
mockedAxios.isAxiosError = jest.requireActual('axios').isAxiosError;

const makeConfig = (overrides: Partial<SplunkSiemConfig> = {}): SplunkSiemConfig => ({
hecUrl: 'https://splunk.corp:8088/services/collector',
hecToken: 'test-hec-token',
retryBaseDelayMs: 1, // keep tests fast
...overrides,
});

const makeEvent = (overrides: Partial<SiemEvent> = {}): 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('SplunkSiemProvider', () => {
let provider: SplunkSiemProvider;
let config: SplunkSiemConfig;

beforeEach(() => {
jest.clearAllMocks();
config = makeConfig();
provider = new SplunkSiemProvider(config);
});

it('should have provider name "splunk"', () => {
expect(provider.providerName).toBe('splunk');
});

describe('forwardEvent', () => {
it('should forward event to the Splunk HEC endpoint', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

const event = makeEvent();
await provider.forwardEvent(event);

expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://splunk.corp:8088/services/collector',
expect.objectContaining({
sourcetype: 'sentinel:security',
event: expect.objectContaining({
event_type: 'suspicious_transaction',
title: 'Suspicious Transaction Detected',
message: 'Large transaction detected from flagged address',
severity: 'high',
source: 'stellar',
}),
}),
{
headers: {
Authorization: 'Splunk test-hec-token',
'Content-Type': 'application/json',
},
},
);
});

it('should use custom source type when provided', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

const customProvider = new SplunkSiemProvider(makeConfig({ sourceType: 'custom:type' }));
await customProvider.forwardEvent(makeEvent());

const body = mockedAxios.post.mock.calls[0][1] as { sourcetype: string };
expect(body.sourcetype).toBe('custom:type');
});

it('should use default source type when not provided', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

await provider.forwardEvent(makeEvent());

const body = mockedAxios.post.mock.calls[0][1] as { sourcetype: string };
expect(body.sourcetype).toBe('sentinel:security');
});

it('should convert the ISO timestamp to epoch seconds', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

await provider.forwardEvent(makeEvent({ timestamp: '2026-06-19T10:00:00.000Z' }));

const body = mockedAxios.post.mock.calls[0][1] as { time: number };
expect(body.time).toBe(Math.floor(new Date('2026-06-19T10:00:00.000Z').getTime() / 1000));
});

it('should include metadata fields in the event payload', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

const event = makeEvent({
metadata: { address: '0x1234', amount: 1000000, riskScore: 0.85 },
});
await provider.forwardEvent(event);

const body = mockedAxios.post.mock.calls[0][1] as { event: Record<string, unknown> };
expect(body.event.address).toBe('0x1234');
expect(body.event.amount).toBe(1000000);
expect(body.event.riskScore).toBe(0.85);
});

it('should succeed on the first attempt without retrying', async () => {
mockedAxios.post.mockResolvedValue({ status: 200 });

await provider.forwardEvent(makeEvent());

expect(mockedAxios.post).toHaveBeenCalledTimes(1);
});

it('should retry on a 503 response and eventually succeed', async () => {
mockedAxios.post
.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 503 },
message: 'Service Unavailable',
})
.mockResolvedValueOnce({ status: 200 });

await provider.forwardEvent(makeEvent());

expect(mockedAxios.post).toHaveBeenCalledTimes(2);
});

it('should retry on a network error with no response', async () => {
mockedAxios.post
.mockRejectedValueOnce({ isAxiosError: true, message: 'ECONNREFUSED' })
.mockResolvedValueOnce({ status: 200 });

await provider.forwardEvent(makeEvent());

expect(mockedAxios.post).toHaveBeenCalledTimes(2);
});

it('should not retry on a 400 Bad Request and should throw immediately', async () => {
mockedAxios.post.mockRejectedValue({
isAxiosError: true,
response: { status: 400, data: { text: 'Invalid event' } },
message: 'Bad Request',
});

await expect(provider.forwardEvent(makeEvent())).rejects.toThrow(
'SplunkSiemProvider.forwardEvent failed',
);
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
});

it('should give up after maxRetries and throw a wrapped error', async () => {
mockedAxios.post.mockRejectedValue({
isAxiosError: true,
response: { status: 500 },
message: 'Internal Server Error',
});

const retryProvider = new SplunkSiemProvider(makeConfig({ maxRetries: 2 }));

await expect(retryProvider.forwardEvent(makeEvent())).rejects.toThrow(
'SplunkSiemProvider.forwardEvent failed',
);
// initial attempt + 2 retries = 3 calls
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
});
});

describe('isHealthy', () => {
it('should return true when the HEC endpoint responds', async () => {
mockedAxios.get.mockResolvedValue({ status: 200 });

const health = await provider.isHealthy();
expect(health).toBe(true);
});

it('should return false when the health check fails', async () => {
mockedAxios.get.mockRejectedValue(new Error('Connection timeout'));

const health = await provider.isHealthy();
expect(health).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,50 @@ import { ISiemProvider } from '../interfaces/siem-provider.interface';
import { SiemEvent } from '../interfaces/siem-event.interface';
import { SplunkSiemConfig } from '../dto/siem-config.dto';

function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Returns true if an error is worth retrying: transient network failures,
* server-side errors (5xx), and rate limiting (429). Returns false for
* other 4xx errors, since retrying malformed requests or bad auth will
* never succeed.
*/
function isRetryable(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false;
}
if (!error.response) {
// Request never reached the server (DNS failure, connection refused, timeout).
return true;
}
const status = error.response.status;
return status === 429 || (status >= 500 && status <= 599);
}

/**
* Forwards Sentinel security events to Splunk via the HTTP Event Collector (HEC).
*
* Environment variables:
* SPLUNK_HEC_URL — HEC endpoint (e.g. https://splunk.corp:8088/services/collector)
* SPLUNK_HEC_TOKEN — HEC authentication token
* SPLUNK_SOURCE_TYPE — optional source type tag (default: "sentinel:security")
* SPLUNK_MAX_RETRIES — optional retry attempts on transient failure (default: 3)
* SPLUNK_RETRY_BASE_DELAY_MS — optional base backoff delay in ms (default: 500)
*/
@Injectable()
export class SplunkSiemProvider implements ISiemProvider {
readonly providerName = 'splunk';
private readonly logger = new Logger(SplunkSiemProvider.name);
private readonly sourceType: string;
private readonly maxRetries: number;
private readonly retryBaseDelayMs: number;

constructor(private readonly config: SplunkSiemConfig) {
this.sourceType = config.sourceType ?? 'sentinel:security';
this.maxRetries = config.maxRetries ?? 3;
this.retryBaseDelayMs = config.retryBaseDelayMs ?? 500;
}

async forwardEvent(event: SiemEvent): Promise<void> {
Expand All @@ -36,21 +64,39 @@ export class SplunkSiemProvider implements ISiemProvider {
},
};

try {
await axios.post(this.config.hecUrl, body, {
headers: {
Authorization: `Splunk ${this.config.hecToken}`,
'Content-Type': 'application/json',
},
});
this.logger.log(`Splunk: forwarded event "${event.eventType}"`);
} catch (error) {
const message = axios.isAxiosError(error)
? (error.response?.data?.text ?? error.message)
: String(error);
this.logger.error(`Splunk: forwardEvent failed: ${message}`);
throw new Error(`SplunkSiemProvider.forwardEvent failed: ${message}`);
let lastError: unknown;

for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
await axios.post(this.config.hecUrl, body, {
headers: {
Authorization: `Splunk ${this.config.hecToken}`,
'Content-Type': 'application/json',
},
});
this.logger.log(`Splunk: forwarded event "${event.eventType}"`);
return;
} catch (error) {
lastError = error;

const isLastAttempt = attempt === this.maxRetries;
if (isLastAttempt || !isRetryable(error)) {
break;
}

const delay = this.retryBaseDelayMs * Math.pow(2, attempt);
this.logger.warn(
`Splunk: forwardEvent attempt ${attempt + 1} failed, retrying in ${delay}ms`,
);
await sleep(delay);
}
}

const message = axios.isAxiosError(lastError)
? (lastError.response?.data?.text ?? lastError.message)
: String(lastError);
this.logger.error(`Splunk: forwardEvent failed: ${message}`);
throw new Error(`SplunkSiemProvider.forwardEvent failed: ${message}`);
}

async isHealthy(): Promise<boolean> {
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/integrations/siem/siem.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import { ISiemProvider } from './interfaces/siem-provider.interface';
hecUrl: process.env.SPLUNK_HEC_URL,
hecToken: process.env.SPLUNK_HEC_TOKEN,
sourceType: process.env.SPLUNK_SOURCE_TYPE,
maxRetries: process.env.SPLUNK_MAX_RETRIES
? parseInt(process.env.SPLUNK_MAX_RETRIES, 10)
: undefined,
retryBaseDelayMs: process.env.SPLUNK_RETRY_BASE_DELAY_MS
? parseInt(process.env.SPLUNK_RETRY_BASE_DELAY_MS, 10)
: undefined,
}),
);
}
Expand Down
Loading