Files
safetynet/tests/siem.test.mjs
T

89 lines
11 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync,readFileSync,rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { openStore } from '../apps/server/store.mjs';
import { startSIEM } from '../apps/server/siem.mjs';
import { toECS,toCEF } from '../packages/core/siem.mjs';
test('CEF escapes headers and extension delimiters; ECS excludes raw content',()=>{
const e={id:1,time:new Date().toISOString(),device_id:'device',kind:'dlp.block',metadata:{ruleId:'a=b\nc',policyVersion:2,prompt:'must never be exported'}};
const cef=toCEF(e,'install');assert.ok(cef.includes('cs1=a\\=b\\nc'));assert.ok(cef.startsWith('CEF:0|'));assert.equal(cef.includes('must never'),false);
const ecs=toECS(e,'install');assert.equal(ecs.event.id,'install:1');assert.equal(JSON.stringify(ecs).includes('must never'),false);assert.deepEqual(ecs.event.type,['denied']);
});
test('durable file exporter resumes after restart and advances only written events',async t=>{
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory);
let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});});
store.event('device','dlp.block',{ruleId:'secret',policyVersion:1});exporter=startSIEM(store,directory,{});await exporter.flush();
assert.equal(exporter.status().cursor,1);exporter.stop();
store.event(null,'policy.published',{actor:'admin',version:2});exporter=startSIEM(store,directory,{});await exporter.flush();assert.equal(exporter.status().cursor,2);
const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(v=>JSON.parse(v));assert.equal(rows.length,2);assert.notEqual(rows[0].event.id,rows[1].event.id);
});
test('remote SIEM export refuses plaintext endpoints',t=>{
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory);
t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});assert.throws(()=>startSIEM(store,directory,{SIEM_HTTP_URL:'http://example.com'}),/HTTPS/);
});
test('ECS and CEF correlate validated trace identities without exporting arbitrary trace text',()=>{
const e={id:1,time:new Date().toISOString(),kind:'clipboard.copy',metadata:{traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef'}};
assert.equal(toECS(e).trace.id,e.metadata.traceId);assert.match(toCEF(e),/cs4Label=TraceId/);
e.metadata.traceId='token=synthetic-secret';e.metadata.spanId='invalid';assert.equal(toECS(e).trace,undefined);assert.equal(toCEF(e).includes('synthetic-secret'),false);
});
test('denied agent actions and correlated Sigma alerts export durably without raw source content',async t=>{
const directory=mkdtempSync(join(tmpdir(),'safetynet-sigma-')),store=openStore(directory);let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});});
const p=store.get('policy');p.security.sigma.rules=[{id:'agent-denial',title:'Agent denial',level:'high',detection:{selection:{kind:'agent.denied'},condition:'selection'}}];store.put('policy',p);
store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked',policyVersion:1,prompt:'excluded raw content'});store.db.exec('COMMIT');
const stored=store.db.prepare('SELECT * FROM events ORDER BY id').all().map(e=>({...e,metadata:JSON.parse(e.metadata)}));assert.equal(stored.length,2);assert.equal(stored[1].kind,'sigma.alert');assert.deepEqual(stored[1].metadata,{ruleId:'agent-denial',level:'high',sourceEventId:stored[0].id});assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1);
assert.deepEqual(toECS(stored[0],'install').event.type,['denied']);const alert=toECS(stored[1],'install');assert.equal(alert.event.kind,'alert');assert.deepEqual(alert.event.category,['intrusion_detection']);assert.deepEqual(alert.event.type,['info']);assert.equal(alert.event.severity,7);assert.equal(alert.safetynet.source_event_id,`install:${stored[0].id}`);assert.match(toCEF(stored[1],'install'),/cs2Label=SourceEventId cs2=install:1/);
exporter=startSIEM(store,directory,{});await exporter.flush();const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(JSON.parse);assert.equal(rows.length,2);assert.equal(rows[1].event.action,'sigma.alert');assert.equal(rows[1].safetynet.source_event_id,rows[0].event.id);assert.equal(JSON.stringify(rows).includes('excluded raw content'),false);
store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked'});store.db.exec('ROLLBACK');assert.equal(store.db.prepare('SELECT count(*) AS n FROM events').get().n,2);assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1);
});
test('administrator forwarding toggle stops HTTPS export, preserves audit, resumes cursor after restart',async t=>{
const {createServer}=await import('node:https');
const certPath=new URL('./fixtures/icap/cert.pem',import.meta.url),keyPath=new URL('./fixtures/icap/key.pem',import.meta.url);
const batches=[];let exporter;
const receiver=createServer({cert:readFileSync(certPath),key:readFileSync(keyPath)},(req,res)=>{let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{batches.push(body.trim().split('\n').map(JSON.parse));res.end('{}');});});
await new Promise(resolve=>receiver.listen(0,'127.0.0.1',resolve));
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-toggle-')),store=openStore(directory);
t.after(async()=>{exporter?.stop();await new Promise(resolve=>receiver.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
const env={SIEM_HTTP_URL:`https://127.0.0.1:${receiver.address().port}/events`,SIEM_CA_FILE:certPath};
store.event('device','download.scan',{ruleId:'synthetic'});exporter=startSIEM(store,directory,env);await exporter.flush();assert.equal(exporter.status().cursor,1);
const disabled=exporter.setEnabled({enabled:false,expectedRevision:1},'admin');assert.equal(disabled.enabled,false);
store.event('device','clipboard.copy',{});await exporter.flush();assert.equal(batches.length,1);assert.equal(exporter.status().cursor,1);assert.equal(exporter.status().pending,2);
assert.throws(()=>exporter.setEnabled({enabled:true,expectedRevision:1},'admin'),/revision conflict/);
exporter.stop();exporter=startSIEM(store,directory,env);await exporter.flush();assert.equal(exporter.status().enabled,false);assert.equal(batches.length,1);
exporter.setEnabled({enabled:true,expectedRevision:2},'admin');await exporter.flush();assert.equal(batches.length,2);assert.equal(exporter.status().pending,0);
assert.deepEqual(batches.flat().map(e=>e.event.id),[1,2,3,4].map(id=>`${store.get('installationId')}:${id}`));
assert.equal(store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='siem.forwarding-changed'").get().n,2);
assert.equal(JSON.stringify(exporter.status()).includes('/events'),false);
});
test('disabling forwarding aborts an unacknowledged HTTPS request without advancing cursor',async t=>{
const {createServer}=await import('node:https');let arrived,closed;const arrival=new Promise(resolve=>arrived=resolve),closure=new Promise(resolve=>closed=resolve);
const receiver=createServer({cert:readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key:readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url))},(req,res)=>{req.resume();res.on('close',closed);req.on('end',arrived);});
await new Promise(resolve=>receiver.listen(0,'127.0.0.1',resolve));
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-abort-')),store=openStore(directory);let exporter;
t.after(async()=>{exporter?.stop();receiver.closeAllConnections();await new Promise(resolve=>receiver.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
store.event('device','download.scan',{});exporter=startSIEM(store,directory,{SIEM_HTTP_URL:`https://127.0.0.1:${receiver.address().port}`,SIEM_CA_FILE:new URL('./fixtures/icap/cert.pem',import.meta.url)});
await arrival;const active=exporter.flush();exporter.setEnabled({enabled:false,expectedRevision:1},'admin');await active;await closure;
assert.equal(exporter.status().cursor,0);assert.equal(exporter.status().failures,0);assert.equal(exporter.status().pending,2);
});
test('SIEM management API enforces authentication, administrator role, origin, strict settings and CAS',async t=>{
const {createApp}=await import('../apps/server/app.mjs');const {digest}=await import('../apps/server/store.mjs');
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-api-')),store=openStore(directory),exporter=startSIEM(store,directory,{SIEM_ENABLED:'false'});
const adminToken='synthetic-admin-credential-012345678901234567890';const config={publicUrl:'http://127.0.0.1:4310',adminToken,secure:false,siemControl:exporter};
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.on('listening',resolve));
t.after(async()=>{exporter.stop();await new Promise(resolve=>server.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
const base=`http://127.0.0.1:${server.address().port}`;
async function api(method,body,headers={authorization:`Bearer ${adminToken}`}){const r=await fetch(base+'/api/admin/siem',{method,headers:{...headers,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}
assert.equal((await api('GET',null,{})).status,401);const initial=await api('GET');assert.equal(initial.status,200);assert.equal(initial.body.enabled,false);
for(const role of ['operator','auditor']){const session=`synthetic-${role}-session-012345678901234567890`;store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(session),Date.now()+60000,JSON.stringify({sub:role,role}));const headers={cookie:`sn_session=${session}`,origin:config.publicUrl};assert.equal((await api('GET',null,headers)).status,200);assert.equal((await api('PUT',{enabled:true,expectedRevision:1},headers)).status,403);}
assert.equal((await api('PUT',{enabled:true,expectedRevision:1},{authorization:`Bearer ${adminToken}`,origin:'https://untrusted.example'})).status,403);
assert.equal((await api('PUT',{enabled:true,expectedRevision:1,endpoint:'https://untrusted.example'})).status,400);
const enabled=await api('PUT',{enabled:true,expectedRevision:1});assert.equal(enabled.status,200);assert.equal(enabled.body.revision,2);
assert.equal((await api('PUT',{enabled:false,expectedRevision:1})).status,409);assert.equal((await api('GET')).body.enabled,true);
assert.equal(store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='siem.forwarding-changed'").get().n,1);
config.siemControl=null;assert.equal((await api('GET')).status,503);assert.equal((await api('PUT',{enabled:false,expectedRevision:2})).status,503);
});