Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { ReportingModule } from './modules/reporting/reporting.module';
import { DependencyTrackerModule } from './modules/contracts/dependencies/dependency-tracker.module';
import { GovernanceModule } from './modules/governance/governance.module';
import { SiemModule } from './integrations/siem/siem.module';
import { ChainsModule } from './modules/chains/chains.module';

@Module({
imports: [
Expand All @@ -15,6 +17,8 @@ import { GovernanceModule } from './modules/governance/governance.module';
ReportingModule,
DependencyTrackerModule,
GovernanceModule,
SiemModule,
ChainsModule,
],
controllers: [AppController],
})
Expand Down
23 changes: 23 additions & 0 deletions apps/backend/src/integrations/siem/dto/siem-config.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Configuration for the Splunk HTTP Event Collector (HEC) provider.
*/
export interface SplunkSiemConfig {
/** Full URL to the Splunk HEC endpoint (e.g. https://splunk.corp:8088/services/collector). */
hecUrl: string;
/** HEC token used for authentication. */
hecToken: string;
/** Splunk source type tag applied to every event (default: "sentinel:security"). */
sourceType?: string;
}

/**
* Configuration for the Elastic SIEM (ECS) provider.
*/
export interface ElasticSiemConfig {
/** Full URL to the Elasticsearch cluster (e.g. https://elastic.corp:9200). */
elasticUrl: string;
/** Elasticsearch API key for authentication. */
apiKey: string;
/** Target index for Sentinel events (default: "sentinel-events"). */
index?: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Normalized security event forwarded to SIEM platforms.
* All provider adapters receive this common shape.
*/
export interface SiemEvent {
/** ISO-8601 timestamp of when the event occurred. */
timestamp: string;
/** Short machine-readable event type (e.g. "suspicious_transaction"). */
eventType: string;
/** Human-readable summary. */
title: string;
/** Full description with contextual detail. */
message: string;
severity: 'low' | 'medium' | 'high' | 'critical';
/** Source chain or system (e.g. "stellar", "ethereum", "internal"). */
source: string;
/** Arbitrary key-value pairs for provider-specific enrichment. */
metadata?: Record<string, unknown>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { SiemEvent } from './siem-event.interface';

/**
* Contract every SIEM provider adapter must implement.
* Add new platforms (QRadar, Sentinel, etc.) by implementing this interface
* and registering the adapter in SiemModule — no changes to SiemService needed.
*/
export interface ISiemProvider {
/** Unique identifier for this provider (e.g. "splunk", "elastic"). */
readonly providerName: string;

/** Forward a normalized security event to the SIEM platform. */
forwardEvent(event: SiemEvent): Promise<void>;

/** Return true when the provider endpoint is reachable and configured. */
isHealthy(): Promise<boolean>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { ISiemProvider } from '../interfaces/siem-provider.interface';
import { SiemEvent } from '../interfaces/siem-event.interface';
import { ElasticSiemConfig } from '../dto/siem-config.dto';

/**
* Forwards Sentinel security events to Elasticsearch using the ECS-aligned
* Bulk API. Events land in a configurable index for Elastic SIEM rules to consume.
*
* Environment variables:
* ELASTIC_URL — Elasticsearch cluster URL (e.g. https://elastic.corp:9200)
* ELASTIC_API_KEY — API key for authentication
* ELASTIC_INDEX — target index (default: "sentinel-events")
*/
@Injectable()
export class ElasticSiemProvider implements ISiemProvider {
readonly providerName = 'elastic';
private readonly logger = new Logger(ElasticSiemProvider.name);
private readonly index: string;

constructor(private readonly config: ElasticSiemConfig) {
this.index = config.index ?? 'sentinel-events';
}

async forwardEvent(event: SiemEvent): Promise<void> {
const doc = {
'@timestamp': event.timestamp,
'event.kind': 'alert',
'event.category': 'intrusion_detection',
'event.type': event.eventType,
'event.severity': this.severityToNumeric(event.severity),
message: event.message,
labels: {
title: event.title,
source: event.source,
severity: event.severity,
},
...event.metadata,
};

const body =
[JSON.stringify({ index: { _index: this.index } }), JSON.stringify(doc)].join('\n') + '\n';

try {
await axios.post(`${this.config.elasticUrl}/_bulk`, body, {
headers: {
Authorization: `ApiKey ${this.config.apiKey}`,
'Content-Type': 'application/x-ndjson',
},
});
this.logger.log(`Elastic: forwarded event "${event.eventType}"`);
} catch (error) {
const message = axios.isAxiosError(error)
? (error.response?.data?.error?.reason ?? error.message)
: String(error);
this.logger.error(`Elastic: forwardEvent failed: ${message}`);
throw new Error(`ElasticSiemProvider.forwardEvent failed: ${message}`);
}
}

async isHealthy(): Promise<boolean> {
try {
const response = await axios.get(`${this.config.elasticUrl}/_cluster/health`, {
headers: { Authorization: `ApiKey ${this.config.apiKey}` },
});
const status: string = response.data?.status ?? 'red';
return status !== 'red';
} catch (error) {
this.logger.warn(`Elastic health check failed: ${String(error)}`);
return false;
}
}

private severityToNumeric(severity: SiemEvent['severity']): number {
const map: Record<SiemEvent['severity'], number> = {
low: 25,
medium: 50,
high: 75,
critical: 100,
};
return map[severity];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { ISiemProvider } from '../interfaces/siem-provider.interface';
import { SiemEvent } from '../interfaces/siem-event.interface';
import { SplunkSiemConfig } from '../dto/siem-config.dto';

/**
* 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")
*/
@Injectable()
export class SplunkSiemProvider implements ISiemProvider {
readonly providerName = 'splunk';
private readonly logger = new Logger(SplunkSiemProvider.name);
private readonly sourceType: string;

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

async forwardEvent(event: SiemEvent): Promise<void> {
const body = {
time: Math.floor(new Date(event.timestamp).getTime() / 1000),
sourcetype: this.sourceType,
event: {
event_type: event.eventType,
title: event.title,
message: event.message,
severity: event.severity,
source: event.source,
...event.metadata,
},
};

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}`);
}
}

async isHealthy(): Promise<boolean> {
try {
await axios.get(this.config.hecUrl, {
headers: { Authorization: `Splunk ${this.config.hecToken}` },
validateStatus: status => status < 500,
});
return true;
} catch (error) {
this.logger.warn(`Splunk health check failed: ${String(error)}`);
return false;
}
}
}
52 changes: 52 additions & 0 deletions apps/backend/src/integrations/siem/siem.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Module } from '@nestjs/common';
import { SiemService } from './siem.service';
import { SplunkSiemProvider } from './providers/splunk.siem-provider';
import { ElasticSiemProvider } from './providers/elastic.siem-provider';
import { ISiemProvider } from './interfaces/siem-provider.interface';

/**
* SIEM Integration Module.
*
* Providers are registered conditionally based on environment variables so
* deployments without a SIEM platform incur no overhead.
*
* To add a new provider:
* 1. Implement ISiemProvider in providers/
* 2. Add its config env vars to .env.example
* 3. Register it in the SIEM_PROVIDERS factory below
*/
@Module({
providers: [
SiemService,
{
provide: 'SIEM_PROVIDERS',
useFactory: (): ISiemProvider[] => {
const providers: ISiemProvider[] = [];

if (process.env.SPLUNK_HEC_URL && process.env.SPLUNK_HEC_TOKEN) {
providers.push(
new SplunkSiemProvider({
hecUrl: process.env.SPLUNK_HEC_URL,
hecToken: process.env.SPLUNK_HEC_TOKEN,
sourceType: process.env.SPLUNK_SOURCE_TYPE,
}),
);
}

if (process.env.ELASTIC_URL && process.env.ELASTIC_API_KEY) {
providers.push(
new ElasticSiemProvider({
elasticUrl: process.env.ELASTIC_URL,
apiKey: process.env.ELASTIC_API_KEY,
index: process.env.ELASTIC_INDEX,
}),
);
}

return providers;
},
},
],
exports: [SiemService],
})
export class SiemModule {}
76 changes: 76 additions & 0 deletions apps/backend/src/integrations/siem/siem.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SiemService } from './siem.service';
import { ISiemProvider } from './interfaces/siem-provider.interface';
import { SiemEvent } from './interfaces/siem-event.interface';

const makeEvent = (overrides: Partial<SiemEvent> = {}): SiemEvent => ({
timestamp: new Date().toISOString(),
eventType: 'test_event',
title: 'Test Event',
message: 'A test security event',
severity: 'low',
source: 'stellar',
...overrides,
});

const makeProvider = (name: string, healthy = true): jest.Mocked<ISiemProvider> => ({
providerName: name,
forwardEvent: jest.fn().mockResolvedValue(undefined),
isHealthy: jest.fn().mockResolvedValue(healthy),
});

describe('SiemService', () => {
let service: SiemService;
let providerA: jest.Mocked<ISiemProvider>;
let providerB: jest.Mocked<ISiemProvider>;

beforeEach(async () => {
providerA = makeProvider('splunk');
providerB = makeProvider('elastic');

const module: TestingModule = await Test.createTestingModule({
providers: [SiemService, { provide: 'SIEM_PROVIDERS', useValue: [providerA, providerB] }],
}).compile();

service = module.get<SiemService>(SiemService);
});

it('forwards events to all providers', async () => {
const event = makeEvent();
await service.forwardEvent(event);
expect(providerA.forwardEvent).toHaveBeenCalledWith(event);
expect(providerB.forwardEvent).toHaveBeenCalledWith(event);
});

it('continues delivery when one provider fails', async () => {
providerA.forwardEvent.mockRejectedValue(new Error('network error'));
const event = makeEvent({ severity: 'critical' });
await expect(service.forwardEvent(event)).resolves.not.toThrow();
expect(providerB.forwardEvent).toHaveBeenCalledWith(event);
});

it('logs a warning and skips when no providers are configured', async () => {
const emptyModule = await Test.createTestingModule({
providers: [SiemService, { provide: 'SIEM_PROVIDERS', useValue: [] }],
}).compile();

const emptyService = emptyModule.get<SiemService>(SiemService);
await expect(emptyService.forwardEvent(makeEvent())).resolves.not.toThrow();
});

it('returns health status for all providers', async () => {
providerB.isHealthy.mockResolvedValue(false);
const health = await service.getProvidersHealth();
expect(health).toEqual({ splunk: true, elastic: false });
});

it('returns false for a provider whose health check throws', async () => {
providerA.isHealthy.mockRejectedValue(new Error('timeout'));
const health = await service.getProvidersHealth();
expect(health.splunk).toBe(false);
});

it('returns all provider names', () => {
expect(service.getProviderNames()).toEqual(['splunk', 'elastic']);
});
});
Loading
Loading