2026-10-13 10:33:31 +02:00
import { appendFileSync , readFileSync } from 'node:fs' ;
import { join } from 'node:path' ;
import { request } from 'node:https' ;
2026-10-13 16:58:15 +02:00
import { z } from 'zod' ;
2026-10-13 10:33:31 +02:00
import { toECS , toCEF } from '../../packages/core/siem.mjs' ;
import { digest } from './store.mjs' ;
2026-10-13 16:58:15 +02:00
const controlKey = 'siem.forwarding' ;
2026-10-13 10:33:31 +02:00
export function startSIEM ( store , directory , env = process . env ) {
2026-10-13 16:58:15 +02:00
let stopped = false , active = null , failures = 0 , nextAttempt = 0 , generation = 0 , inFlight = null ;
2026-10-13 10:33:31 +02:00
const url = env . SIEM_HTTP_URL ? new URL ( env . SIEM_HTTP_URL ) : null ;
if ( url && ( url . protocol !== 'https:' || url . username || url . password )) throw Error ( 'SIEM push requires an HTTPS URL without embedded credentials' );
const tlsOptions = {...( env . SIEM_CA_FILE ? { ca : readFileSync ( env . SIEM_CA_FILE )} : {}),...( env . SIEM_CERT_FILE ? { cert : readFileSync ( env . SIEM_CERT_FILE )} : {}),...( env . SIEM_KEY_FILE ? { key : readFileSync ( env . SIEM_KEY_FILE )} : {})};
const format = env . SIEM_FORMAT || 'ecs' ; if ( ! [ 'ecs' , 'cef' ]. includes ( format )) throw Error ( 'SIEM_FORMAT must be ecs or cef' );
const cursorKey = url ? `siem.remote. ${ digest ( url . href + ':' + format ) } ` : `siem.file. ${ format } ` ;
2026-10-13 16:58:15 +02:00
if ( ! store . get ( controlKey )) store . put ( controlKey ,{ enabled : env . SIEM_ENABLED !== 'false' , revision : 1 });
const control = ()=> store . get ( controlKey );
async function work (){
const epoch = generation ;
2026-10-13 10:33:31 +02:00
try {
const after = store . get ( cursorKey ) || 0 ;
2026-10-13 16:58:15 +02:00
const rows = store . db . prepare ( 'SELECT * FROM events WHERE id>? ORDER BY id LIMIT 100' ). all ( after ). map ( e =>({... e , metadata : JSON . parse ( e . metadata ), audit : store . auditIntegrity . linkage ( e . id )}));
2026-10-13 10:33:31 +02:00
if ( ! rows . length ) return ;
const body = rows . map ( e => format === 'ecs' ? JSON . stringify ( toECS ( e , store . get ( 'installationId' ))) : toCEF ( e , store . get ( 'installationId' ))). join ( '\n' ) + '\n' ;
2026-10-13 16:58:15 +02:00
if ( url ) await push ( url , body , format , env . SIEM_HTTP_TOKEN , tlsOptions , req =>{ inFlight = req ;});
2026-10-13 10:33:31 +02:00
else appendFileSync ( join ( directory , `security. ${ format === 'ecs' ? 'ndjson' : 'cef' } ` ), body ,{ mode : 0o600 , flush : true });
2026-10-13 16:58:15 +02:00
if ( ! stopped && epoch === generation && control (). enabled ) store . put ( cursorKey , rows . at ( - 1 ). id );
failures = 0 ; nextAttempt = 0 ;
} catch { if ( ! stopped && epoch === generation && control (). enabled ){ failures ++ ; nextAttempt = Date . now () + Math . min ( 300000 , 1000 * 2 ** Math . min ( failures , 8 ));}}
finally { inFlight = null ;}
2026-10-13 10:33:31 +02:00
}
2026-10-13 16:58:15 +02:00
function tick (){
if ( active ) return active ;
if ( stopped ||! control (). enabled || Date . now () < nextAttempt ) return Promise . resolve ();
active = work (). finally (()=>{ active = null ;}); return active ;
}
function setEnabled ({ enabled , expectedRevision }, actor ){
const settings = z . object ({ enabled : z . boolean (), expectedRevision : z . number (). int (). positive ()}). strict (). parse ({ enabled , expectedRevision });
const previous = control (); if ( previous . revision !== settings . expectedRevision ){ const e = Error ( 'SIEM configuration revision conflict' ); e . status = 409 ; throw e ;}
const next = { enabled : settings . enabled , revision : previous . revision + 1 };
store . db . exec ( 'BEGIN IMMEDIATE' );
try { store . put ( controlKey , next ); store . event ( null , 'siem.forwarding-changed' ,{ actor , enabled : next . enabled , version : next . revision }); store . db . exec ( 'COMMIT' );}
catch ( error ){ store . db . exec ( 'ROLLBACK' ); throw error ;}
generation ++ ; nextAttempt = 0 ; failures = 0 ;
if ( ! next . enabled ) inFlight ? . destroy ( Error ( 'SIEM disabled' ));
// Re-enable uses the existing cursor. Events remain in the durable audit store.
return status ();
}
function status (){ return { configured :!! url , format ,... control (), failures , cursor : store . get ( cursorKey ) || 0 , pending : store . db . prepare ( 'SELECT count(*) AS n FROM events WHERE id>?' ). get ( store . get ( cursorKey ) || 0 ). n };}
2026-10-13 10:33:31 +02:00
const timer = setInterval ( tick , 2000 ); timer . unref (); tick ();
2026-10-13 16:58:15 +02:00
return { stop : ()=>{ stopped = true ; generation ++ ; clearInterval ( timer ); inFlight ? . destroy ( Error ( 'SIEM stopped' ));}, flush : tick , status , setEnabled };
2026-10-13 10:33:31 +02:00
}
2026-10-13 16:58:15 +02:00
// Mount after the application's authenticated /api/admin middleware. Its route
// permission mapping must explicitly allow reads and reserve writes to admins.
export function installSIEMRoutes ( app , store , config ){
app . get ( '/api/admin/siem' ,( _req , res )=> config . siemControl ? res . json ( config . siemControl . status ()) : res . status ( 503 ). json ({ error : 'SIEM exporter unavailable' }));
app . put ( '/api/admin/siem' ,( req , res , next )=>{
if ( req . principal ? . role !== 'admin' ) return res . status ( 403 ). json ({ error : 'Administrator role required' });
if ( ! config . siemControl ) return res . status ( 503 ). json ({ error : 'SIEM exporter unavailable' });
try { const b = z . object ({ enabled : z . boolean (), expectedRevision : z . number (). int (). positive ()}). strict (). parse ( req . body ); res . json ( config . siemControl . setEnabled ( b , req . principal . sub ));}
catch ( error ){ if ( error . status === 409 ) return res . status ( 409 ). json ({ error : error . message }); next ( error );}
});
}
function push ( url , body , format , credential , tlsOptions , onRequest ) {
2026-10-13 10:33:31 +02:00
return new Promise (( resolve , reject )=>{
const req = request ( url ,{ method : 'POST' ,... tlsOptions , headers : { 'content-type' : format === 'ecs' ? 'application/x-ndjson' : 'text/plain' , 'content-length' : Buffer . byteLength ( body ),...( credential ? { authorization : `Bearer ${ credential } ` } : {})}}, res =>{
res . resume (); res . on ( 'error' , reject ); res . on ( 'end' ,()=> res . statusCode >= 200 && res . statusCode < 300 ? resolve () : reject ( Error ( 'SIEM rejected batch' )));
2026-10-13 16:58:15 +02:00
}); onRequest ( req ); req . setTimeout ( 10000 ,()=> req . destroy ( Error ( 'SIEM timeout' ))); req . on ( 'error' , reject ); req . end ( body );
2026-10-13 10:33:31 +02:00
});
}