Files
safetynet/apps/server/audit-retention.mjs
T

54 lines
4.2 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
import {z} from 'zod';
const Options=z.object({days:z.number().int().min(1).max(3650),limit:z.number().int().min(1).max(1000).default(500)}).strict();
// Only the active exporter's durable cursor permits deletion. Old cursor keys
// from an earlier SIEM configuration never authorize the current destination.
export function pruneAuditEvents(store,{days,limit=500,exportedThrough,now=Date.now(),dryRun=true}){
const options=Options.parse({days,limit});
if(!Number.isSafeInteger(exportedThrough)||exportedThrough<0)throw Error('Verified SIEM export cursor required');
if(!Number.isFinite(now))throw Error('Invalid retention clock');
const cutoff=new Date(now-options.days*86400000).toISOString();
const db=store.db;db.exec('SAVEPOINT audit_retention');
try{
const hasCases=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='incident_cases'").get();
const protectedCase=hasCases?'AND NOT EXISTS (SELECT 1 FROM incident_cases c WHERE c.event_id=e.id)':'';
const hasEvidence=!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='protected_evidence'").get();
const protectedEvidence=hasEvidence?'AND NOT EXISTS (SELECT 1 FROM protected_evidence p WHERE p.source_event_id=e.id)':'';
const candidates=db.prepare(`SELECT e.id FROM events e WHERE e.kind!='audit.retention-applied' AND e.time<? AND e.id<=? ${protectedCase} ${protectedEvidence} ORDER BY e.id LIMIT ?`).all(cutoff,exportedThrough,options.limit);
2026-10-13 10:33:31 +02:00
const ids=new Set(candidates.map(r=>r.id));
// A batch boundary must not leave a retained correlation pointing at an event
// we removed. It is safe to remove children first and their source next run.
const bound=candidates.map(()=>'?').join(',');
const children=candidates.length?db.prepare(`SELECT id,json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND id IN (${bound})`).all(...ids):[];
const retainedSources=candidates.length?db.prepare(`SELECT DISTINCT json_extract(metadata,'$.sourceEventId') AS source FROM events WHERE kind='sigma.alert' AND json_extract(metadata,'$.sourceEventId') IN (${bound}) AND id NOT IN (${bound})`).all(...ids,...ids):[];
for(const row of retainedSources)ids.delete(row.source);
let changed;do{changed=false;for(const child of children){if(ids.has(child.source)&&!ids.has(child.id)){ids.delete(child.source);changed=true;}}}while(changed);
const selected=[...ids];
if(!dryRun&&selected.length){
const placeholders=selected.map(()=>'?').join(',');
store.event(null,'audit.retention-applied',{count:selected.length,retentionDays:days,exportedThrough});
const retentionEventId=db.prepare('SELECT max(id) AS id FROM events').get().id;
store.auditIntegrity.authorizePrune(selected,retentionEventId);
2026-10-13 10:33:31 +02:00
db.prepare(`DELETE FROM alerts WHERE event_id IN (${placeholders})`).run(...selected);
db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...selected);
// Device event UUIDs deliberately survive: removing them would let a replay
// recreate expired events. No signing/history/enrollment state is pruned.
}
db.exec('RELEASE audit_retention');
return {dryRun,cutoff,eligible:selected.length,exportedThrough,incidentEvidencePreserved:true,deduplicationPreserved:true};
}catch(error){db.exec('ROLLBACK TO audit_retention');db.exec('RELEASE audit_retention');throw error;}
}
export function startAuditRetention(store,siem,{days=0,intervalMs=3600000}={}){
if(!Number.isInteger(days)||days<0||days>3650)throw Error('AUDIT_RETENTION_DAYS must be 0 or 1..3650');
if(days)store.db.exec("CREATE INDEX IF NOT EXISTS audit_retention_time ON events(time,id); CREATE INDEX IF NOT EXISTS audit_sigma_source ON events(json_extract(metadata,'$.sourceEventId')) WHERE kind='sigma.alert';");
let running=false,lastRun=null,lastFailure=null,timer;
function run(){
if(!days||running)return;running=true;
try{const status=siem.status();lastRun={...pruneAuditEvents(store,{days,exportedThrough:status.cursor,dryRun:false}),at:new Date().toISOString()};lastFailure=null;}
catch{lastFailure=new Date().toISOString();}
finally{running=false;}
}
if(days){timer=setInterval(run,intervalMs);timer.unref();}
return {run,stop:()=>clearInterval(timer),status:()=>({enabled:!!days,days,lastRun,lastFailure,scope:'exported-unreferenced-database-events'})};
}