import { appendFileSync,readFileSync } from 'node:fs'; import { join } from 'node:path'; import { request } from 'node:https'; import { z } from 'zod'; import { toECS,toCEF } from '../../packages/core/siem.mjs'; import { digest } from './store.mjs'; const controlKey='siem.forwarding'; export function startSIEM(store,directory,env=process.env) { let stopped=false,active=null,failures=0,nextAttempt=0,generation=0,inFlight=null; 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}`; 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; try{ const after=store.get(cursorKey)||0; 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)})); 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'; if(url)await push(url,body,format,env.SIEM_HTTP_TOKEN,tlsOptions,req=>{inFlight=req;}); else appendFileSync(join(directory,`security.${format==='ecs'?'ndjson':'cef'}`),body,{mode:0o600,flush:true}); 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;} } function tick(){ if(active)return active; if(stopped||!control().enabled||Date.now(){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};} const timer=setInterval(tick,2000);timer.unref();tick(); return {stop:()=>{stopped=true;generation++;clearInterval(timer);inFlight?.destroy(Error('SIEM stopped'));},flush:tick,status,setEnabled}; } // 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) { 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'))); });onRequest(req);req.setTimeout(10000,()=>req.destroy(Error('SIEM timeout')));req.on('error',reject);req.end(body); }); }