Files
safetynet/tests/broker-audit-gate.test.mjs
T

89 lines
8.7 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {createServer,request as httpRequest} from 'node:http';
import {once} from 'node:events';
import {mkdtemp,open,readFile,rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const require=createRequire(import.meta.url),{createBrokerAudit,BrokerAuditEventSchema}=require('../apps/network-broker/audit-gate.cjs');
const {gateRequest}=require('../apps/network-broker/http-request-gate.cjs');
async function fixture(t,configure={}){
const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-audit-')),journal=path.join(root,'synthetic-events.jsonl');
const received=[];let connections=0;
const receiver=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('received');});receiver.on('connection',()=>connections++);receiver.listen(0,'127.0.0.1');await once(receiver,'listening');
let valid=true,persistenceCalls=0;
const durable=async event=>{
persistenceCalls++;BrokerAuditEventSchema.parse(event);
await configure.beforePersist?.(event);
// Synthetic metadata only. Production requires the existing protected,
// encrypted durable journal, not this test's plain fsync demonstration.
const file=await open(journal,'a');try{await file.writeFile(JSON.stringify(event)+'\n');await file.sync();}finally{await file.close();}
return {persisted:true,eventId:event.eventId};
};
const audit=createBrokerAudit({current:()=>valid,policyVersion:3,persist:configure.persist||durable,correlation:()=>({traceId:'a'.repeat(32),spanId:'b'.repeat(16)})});
const destination=`http://127.0.0.1:${receiver.address().port}/upload`;
const proxy=createServer(async(req,res)=>{
try{
await gateRequest(req,{authority:'policy-3',current:()=>valid,deadlineMs:1000,inspect:async({body})=>{
const decision=body.toString().includes('PRIVATE_TEST_SECRET')?'block':'allow';
await audit.record(decision,{bytes:body.length,detectedType:'text',host:'127.0.0.1'});return {decision};
},forward:({body})=>new Promise((resolve,reject)=>{const outgoing=httpRequest(destination,{method:'POST',headers:{'content-length':body.length}},response=>{response.resume();response.once('end',resolve);});outgoing.once('error',reject);outgoing.end(body);})});
res.end('allowed');
}catch{res.writeHead(403);res.end('blocked');}
});proxy.listen(0,'127.0.0.1');await once(proxy,'listening');
t.after(async()=>{proxy.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(resolve=>proxy.close(resolve)),new Promise(resolve=>receiver.close(resolve))]);await rm(root,{force:true,recursive:true});});
const send=body=>new Promise((resolve,reject)=>{const outgoing=httpRequest({host:'127.0.0.1',port:proxy.address().port,path:destination,method:'POST',headers:{host:new URL(destination).host,'content-length':Buffer.byteLength(body)}},response=>{response.resume();response.once('end',()=>resolve(response.statusCode));});outgoing.once('error',reject);outgoing.end(body);});
return {send,audit,received,revoke:()=>{valid=false;},get connections(){return connections;},get persistenceCalls(){return persistenceCalls;},events:async()=>String(await readFile(journal)).trim().split('\n').map(line=>JSON.parse(line))};
}
test('pending durable allow audit releases no TCP connection and fsync receipt precedes release',async t=>{
let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}});
const pending=f.send('public payload');await started;assert.equal(f.connections,0);release();assert.equal(await pending,200);
assert.deepEqual(f.received,['public payload']);const events=await f.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.authorized');assert.equal(events[0].policyVersion,3);assert.equal(events[0].traceId,'a'.repeat(32));
});
test('durable block records safe facts without original secret or request fingerprint',async t=>{
const f=await fixture(t);assert.equal(await f.send('PRIVATE_TEST_SECRET'),403);assert.equal(f.connections,0);
const events=await f.events();assert.equal(events[0].kind,'upload.block');assert.equal(events[0].request.bytes,19);assert.equal(events[0].request.detectedType,'text');
const text=JSON.stringify(events);assert.equal(text.includes('PRIVATE_TEST_SECRET'),false);assert.equal(text.includes('sha256'),false);
});
test('audit failure and uncertain receipt block without dial or automatic retry',async t=>{
for(const persist of [async()=>{throw Error('private persistence details');},async()=>({persisted:true,eventId:'wrong-id'})]){
let calls=0;const f=await fixture(t,{persist:async event=>{calls++;return persist(event);}});
assert.equal(await f.send('public'),403);assert.equal(f.connections,0);assert.equal(calls,1);
}
});
test('revocation while audit waits prevents release even after durable write succeeds',async t=>{
let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}}),pending=f.send('public');await started;f.revoke();release();
assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal((await f.events()).length,1);assert.equal(f.persistenceCalls,1);
});
test('missing mandatory persistence and malformed security facts cannot authorize transfer',async()=>{
const audit=createBrokerAudit({current:()=>true,policyVersion:1});
await assert.rejects(audit.record('allow',{bytes:2,detectedType:'text'}),{code:'audit-unavailable'});
for(const facts of [{bytes:-1,detectedType:'text'},{bytes:2,detectedType:'text',body:'private'},{bytes:2,detectedType:'text',sha256:'a'.repeat(64)},{bytes:2,detectedType:'text',host:'https://company.example/private?q=secret'},{bytes:2,detectedType:'text',reason:'broker-inspection-fault'}])await assert.rejects(audit.record('allow',facts),{code:'audit-facts-invalid'});
});
test('fault event uses existing schema fixed reason without error detail',async()=>{
const events=[];const audit=createBrokerAudit({current:()=>false,policyVersion:7,persist:async event=>{events.push(BrokerAuditEventSchema.parse(event));return {persisted:true,eventId:event.eventId};}});
await audit.record('fault',{bytes:0,detectedType:'unknown'});assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'broker-inspection-fault');
});
test('decision reason mismatch and accessors fail with fixed errors without reading secret getters',async()=>{
let evaluated=false;const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>({persisted:true,eventId:event.eventId})});
for(const [decision,reason] of [['allow','broker-policy-block'],['block','broker-approved'],['block','broker-audit-failed'],['fault','broker-authority-expired']])await assert.rejects(audit.record(decision,{bytes:1,detectedType:'text',reason}),{code:'audit-facts-invalid'});
await assert.rejects(audit.record('allow',{get bytes(){evaluated=true;throw Error('private secret');},detectedType:'text'}),{message:'audit-facts-invalid'});assert.equal(evaluated,false);
});
test('only trusted policy and fixed built-in matched IDs enter security events',async()=>{
const events=[],ids=['company-secret'];
const audit=createBrokerAudit({current:()=>true,policyVersion:1,allowedRuleIds:ids,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}});
ids.push('RAW PRIVATE RULE TEXT');
await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'company-secret'});
await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'api-secret'});
assert.deepEqual(events.map(event=>event.ruleId),['company-secret','api-secret']);
for(const [decision,id] of [['allow','company-secret'],['block','RAW PRIVATE RULE TEXT'],['fault','unknown-rule']])await assert.rejects(audit.record(decision,{bytes:4,detectedType:'text',matchedRuleId:id}),{code:'audit-facts-invalid'});
assert.equal(events.length,2);assert.equal(JSON.stringify(events).includes('RAW PRIVATE RULE TEXT'),false);
});
test('canonical bare IPv6 destination is valid security metadata without an URL or port',async()=>{
const events=[],audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}});
await audit.record('allow',{bytes:1,detectedType:'text',host:'::1'});assert.equal(events[0].host,'::1');
await assert.rejects(audit.record('allow',{bytes:1,detectedType:'text',host:'[::1]:443'}),{code:'audit-facts-invalid'});
});