Files
safetynet/apps/server/siem.mjs
T

69 lines
5.1 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import { appendFileSync,readFileSync } from 'node:fs';
import { join } from 'node:path';
import { request } from 'node:https';
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';
const controlKey='siem.forwarding';
2026-10-13 10:33:31 +02:00
export function startSIEM(store,directory,env=process.env) {
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}`;
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;
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';
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});
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
}
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();
return {stop:()=>{stopped=true;generation++;clearInterval(timer);inFlight?.destroy(Error('SIEM stopped'));},flush:tick,status,setEnabled};
2026-10-13 10:33:31 +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')));
});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
});
}