Preserve broker audit causation in central ingestion and SIEM exports

This commit is contained in:
Karl committed 2026-10-14 11:16:09 +02:00
1 parent 9e7cb57093
commit 95d23f4e0b
6 files changed
+132 -3

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {toECS,toCEF} from '../packages/core/siem.mjs';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import {openStore} from '../apps/server/store.mjs';
import {startSIEM} from '../apps/server/siem.mjs';
function event(phase='forward.fault'){
const authorizedEventId=randomUUID();
return {id:2,time:new Date().toISOString(),device_id:'synthetic',kind:phase==='request.authorized'?'upload.authorized':'upload.block',metadata:{eventId:randomUUID(),ruleId:phase==='request.authorized'?'broker-approved':'broker-upstream-failed',policyVersion:4,request:{bytes:128,detectedType:'pdf'},broker:{requestId:randomUUID(),connectionId:randomUUID(),phase,reason:phase==='request.authorized'?'broker-approved':'broker-upstream-failed',...(phase==='forward.fault'?{authorizedEventId}:{})}}};
}
test('ECS retains actual causal UUIDs and marks terminal forwarding failure as error',()=>{
const input=event(),output=toECS(input);
assert.equal(output.event.outcome,'failure');assert.deepEqual(output.event.type,['error']);
assert.equal(output.safetynet.broker.event_id,input.metadata.eventId);
assert.equal(output.safetynet.broker.authorized_event_id,input.metadata.broker.authorizedEventId);
assert.equal(output.safetynet.broker.connection_id,input.metadata.broker.connectionId);
assert.equal(output.trace,undefined);assert.equal(output.span,undefined);
});
test('a durable authorization is not reported as completed delivery',()=>{
const output=toECS(event('request.authorized'));assert.equal(output.event.outcome,'unknown');assert.deepEqual(output.event.type,['allowed']);
});
test('CEF standard custom string preserves all causation without overwriting integrity or type',()=>{
const input=event();input.audit={hash:'synthetic-hash',sequence:3};
const output=toCEF(input);assert.ok(output.includes('cs2Label=BrokerLifecycle'));
for(const value of [input.metadata.eventId,input.metadata.broker.requestId,input.metadata.broker.connectionId,input.metadata.broker.authorizedEventId])assert.ok(output.includes(value));
assert.ok(output.includes('flexString1=synthetic-hash'));assert.ok(output.includes('cs6=pdf'));
assert.ok(output.includes('outcome=failure'));assert.ok(toCEF(event('request.authorized')).includes('outcome=unknown'));
});
test('malformed or content-bearing broker facts are never exported as lifecycle metadata',()=>{
for(const modify of [input=>{input.metadata.broker.body='private-body';},input=>{input.metadata.broker.authorizedEventId=input.metadata.eventId;},input=>{input.metadata.broker.reason='private-reason';}]){
const input=event();modify(input);assert.equal(toECS(input).safetynet.broker,undefined);
const cef=toCEF(input);assert.equal(cef.includes('BrokerLifecycle'),false);assert.equal(cef.includes('private-'),false);
}
});
test('actual durable exporter preserves the authorization-to-fault UUID join',async()=>{
const root=path.resolve(os.tmpdir()),directory=fs.mkdtempSync(path.join(root,'safetynet-broker-siem-'));
if(path.dirname(path.resolve(directory))!==root||!path.basename(directory).startsWith('safetynet-broker-siem-'))throw Error('Synthetic cleanup path rejected');
const store=openStore(directory);let exporter;
try{
const authorized=event('request.authorized'),fault=event();
fault.metadata.broker.authorizedEventId=authorized.metadata.eventId;
fault.metadata.broker.requestId=authorized.metadata.broker.requestId;
store.event('synthetic','upload.authorized',authorized.metadata);store.event('synthetic','upload.block',fault.metadata);
exporter=startSIEM(store,directory,{});await exporter.flush();assert.equal(exporter.status().cursor,2);
const rows=fs.readFileSync(path.join(directory,'security.ndjson'),'utf8').trim().split('\n').map(JSON.parse);
assert.equal(rows.length,2);assert.equal(rows[0].event.outcome,'unknown');assert.equal(rows[1].event.outcome,'failure');
assert.equal(rows[1].safetynet.broker.authorized_event_id,rows[0].safetynet.broker.event_id);
assert.equal(rows[1].safetynet.broker.request_id,rows[0].safetynet.broker.request_id);
}finally{
exporter?.stop();store.db.close();
fs.rmSync(directory,{recursive:true,force:true});
}
});
+15
View File
@@ -26,3 +26,18 @@ test('SIEM suppresses historical upload hashes while preserving transfer amounts
const current={...legacy,metadata:{request:{bytes:18,detectedType:'text'}}};assert.deepEqual(toECS(current).safetynet.request,{bytes:18,detected_type:'text'});assert.match(toCEF(current),/cn3=18/);assert.equal(JSON.stringify(toECS(current)).includes(hash),false);
legacy.kind='download.scan';assert.equal(toECS(legacy).file.hash.sha256,hash);
});
test('central API accepts typed broker causation and rejects invalid lifecycle semantics',async t=>{
const dir=mkdtempSync(join(tmpdir(),'safetynet-request-audit-')),store=openStore(dir),credential='synthetic-device-broker-token';
store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('broker-device','Synthetic',digest(credential));
const server=createApp(store,{publicUrl:'http://127.0.0.1',adminToken:'synthetic-administrator-token-123456789',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});
const send=body=>fetch(`http://127.0.0.1:${server.address().port}/api/device/events`,{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify(body)});
const requestId=randomUUID(),authorizedEventId=randomUUID();
const allow={eventId:authorizedEventId,policyVersion:1,kind:'upload.authorized',ruleId:'broker-approved',request:{bytes:7,detectedType:'text'},broker:{requestId,phase:'request.authorized',reason:'broker-approved'}};
assert.equal((await send(allow)).status,200);
const fault={...allow,eventId:randomUUID(),kind:'upload.block',ruleId:'broker-upstream-failed',broker:{requestId,phase:'forward.fault',reason:'broker-upstream-failed',authorizedEventId}};
assert.equal((await send(fault)).status,200);
for(const broker of [{...fault.broker,authorizedEventId:undefined},{...fault.broker,authorizedEventId:fault.eventId},{...allow.broker,phase:'connect.block'},{...allow.broker,reason:'arbitrary secret details'},{...allow.broker,body:'private'}])assert.equal((await send({...fault,eventId:broker.authorizedEventId===fault.eventId?fault.eventId:randomUUID(),broker})).status,400);
const rows=store.db.prepare('SELECT metadata FROM events ORDER BY id').all();assert.equal(rows.length,2);assert.deepEqual(JSON.parse(rows[1].metadata).broker,fault.broker);assert.equal(store.auditIntegrity.verify().valid,true);
});