Files
safetynet/tests/agent-audit.test.mjs
T

73 lines
7.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import audit from '../packages/core/agent-audit.cjs';
import {toECS,toCEF} from '../packages/core/siem.mjs';
test('worker audit retains immutable principal generation without inventing a foreground session',()=>{
const facts=audit.agentAuditFacts({action:'network:background',outcome:'completed',principalId:'worker-agent',credentialGeneration:3});
assert.equal(facts.scope,'network:background');assert.equal(facts.sessionId,undefined);
assert.deepEqual(audit.agentFactsForEvent('agent.action',facts),facts);
assert.equal(audit.agentFactsForEvent('agent.denied',facts),null);
assert.throws(()=>audit.agentAuditFacts({...facts,credentialGeneration:undefined}));
for(const key of ['body','cookies','authorization','initiator','url'])assert.throws(()=>audit.agentAuditFacts({...facts,[key]:'PRIVATE_WORKER_DATA'}));
});
test('agent audit binds every fixed action to its scope and rejects secret-bearing extras',()=>{
for(const [action,scope] of Object.entries(audit.AGENT_SCOPES)){
const outcome=action==='agent-request'?'denied':'completed';
assert.deepEqual(audit.agentAuditFacts({action,outcome}),{action,scope,outcome});
assert.throws(()=>audit.AgentAuditSchema.parse({action,scope:'wrong',outcome}));
}
for(const extra of ['args','prompt','url','principalId','token'])assert.throws(()=>audit.agentAuditFacts({action:'read',outcome:'completed',[extra]:'synthetic-secret'}));
assert.throws(()=>audit.agentAuditFacts({action:'agent-request',outcome:'authorized'}));
assert.throws(()=>audit.agentAuditFacts({action:'read',outcome:'completed',sessionId:'secret-path'}));
});
test('agent lifecycle exporters preserve trace and chain fields and exclude invalid metadata',()=>{
const sessionId=randomUUID(),metadata={agent:audit.agentAuditFacts({action:'type',outcome:'completed',sessionId}),ruleId:'type:completed',policyVersion:2,traceId:'a'.repeat(32),spanId:'b'.repeat(16)};
const event={id:7,time:new Date().toISOString(),kind:'agent.action',device_id:'device',metadata,audit:{hash:'hash',sequence:7}};
const ecs=toECS(event),cef=toCEF(event);
assert.deepEqual(ecs.safetynet.agent,{action:'type',scope:'browser:interact',outcome:'completed',session_id:sessionId});
for(const value of ['cs1Label=RuleId','cs2Label=AgentAction','cs3Label=AgentScope','cs4Label=TraceId','cs5Label=SpanId','cs6Label=AgentOutcome','flexString1Label=AuditRecordHash',`requestContext=${sessionId}`])assert.ok(cef.includes(value),value);
event.kind='download.released';assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('AgentAction'),false);
event.kind='agent.denied';assert.equal(toECS(event).safetynet.agent,undefined);
metadata.agent={...metadata.agent,outcome:'denied',prompt:'synthetic-secret'};assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('synthetic-secret'),false);
delete metadata.agent.prompt;assert.equal(toECS(event).safetynet.agent.outcome,'denied');
metadata.agent.outcome='failed';assert.equal(toECS(event).event.outcome,'failure');assert.deepEqual(toECS(event).event.type,['error']);
metadata.request={bytes:123,detectedType:'text'};assert.ok(toCEF(event).includes('cs6=failed'));assert.equal(toCEF(event).includes('DetectedRequestType'),false);
});
test('central agent facts ingestion validates kind pairing and rejects unstructured content',async t=>{
const {mkdtempSync,rmSync}=await import('node:fs'),{tmpdir}=await import('node:os'),{join,resolve}=await import('node:path');
const {openStore,digest}=await import('../apps/server/store.mjs'),{createApp}=await import('../apps/server/app.mjs');
const prefix=join(tmpdir(),'safetynet-agent-audit-'),dir=mkdtempSync(prefix),store=openStore(dir),credential='synthetic-device-agent-audit-token';
store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('device','Synthetic',digest(credential));
const server=createApp(store,{publicUrl:'http://127.0.0.1',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();assert.ok(resolve(dir).startsWith(resolve(prefix)));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({eventId:randomUUID(),policyVersion:1,...body})});
const agent=audit.agentAuditFacts({action:'read',outcome:'authorized',sessionId:randomUUID()});
assert.equal((await send({kind:'agent.action',agent})).status,200);
const attributed={...agent,principalId:'agent-'+randomUUID(),credentialGeneration:1};
assert.equal((await send({kind:'agent.action',agent:attributed})).status,200);
for(const partial of [{...agent,principalId:attributed.principalId},{...agent,credentialGeneration:1}])assert.equal((await send({kind:'agent.action',agent:partial})).status,400);
for(const body of [{kind:'agent.denied',agent},{kind:'browser.started',agent},{kind:'agent.action',agent:{...agent,scope:'browser:interact'}},{kind:'agent.action',agent:{...agent,prompt:'synthetic-secret'}}])assert.equal((await send(body)).status,400);
assert.equal((await send({kind:'agent.denied',agent:{...agent,outcome:'denied'}})).status,200);
assert.equal((await send({kind:'agent.action',ruleId:'read:completed'})).status,200); // Older enrolled client compatibility.
const rows=store.db.prepare('SELECT * FROM events').all();assert.equal(rows.length,4);assert.equal(rows.some(row=>row.metadata.includes('synthetic-secret')),false);assert.equal(store.auditIntegrity.verify().valid,true);
});
test('agent principal attribution is bounded, paired and never invented for legacy events',()=>{
const base={action:'read',outcome:'completed'},principalId='agent:'+randomUUID(),credentialGeneration=2;
const parsed=audit.agentAuditFacts({...base,principalId,credentialGeneration});
assert.equal(parsed.principalId,principalId);assert.equal(parsed.credentialGeneration,2);
for(const extra of [{principalId},{credentialGeneration},{principalId:'a'.repeat(81),credentialGeneration},{principalId:'bad principal',credentialGeneration},{principalId:'https://secret',credentialGeneration},{principalId,credentialGeneration:0},{principalId,credentialGeneration:1.5},{principalId,credentialGeneration:Number.MAX_SAFE_INTEGER+1},{principalId:null,credentialGeneration}])assert.throws(()=>audit.agentAuditFacts({...base,...extra}));
assert.equal(audit.agentAuditFacts(base).principalId,undefined);
const event={id:9,time:new Date().toISOString(),kind:'agent.action',metadata:{actor:'human-actor',agent:parsed,ruleId:'read:completed',traceId:'a'.repeat(32),spanId:'b'.repeat(16)},audit:{hash:'hash',sequence:9}};
const ecs=toECS(event),cef=toCEF(event);
assert.equal(ecs.safetynet.agent.principal_id,principalId);assert.equal(ecs.safetynet.agent.credential_generation,2);assert.equal(ecs.user.id,'human-actor');
for(const field of [`suid=${principalId}`,'flexNumber1Label=AgentCredentialGeneration','flexNumber1=2','suser=human-actor','cs1Label=RuleId','cs2Label=AgentAction','cs3Label=AgentScope','cs4Label=TraceId','cs5Label=SpanId','cs6Label=AgentOutcome','flexString1Label=AuditRecordHash'])assert.ok(cef.includes(field),field);
event.metadata.agent=audit.agentAuditFacts(base);assert.equal(toECS(event).safetynet.agent.principal_id,undefined);assert.equal(toCEF(event).includes('suid='),false);assert.equal(toCEF(event).includes('AgentCredentialGeneration'),false);
event.metadata.agent={...parsed,credentialGeneration:undefined};assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('suid='),false);
});