89 lines
8.7 KiB
JavaScript
89 lines
8.7 KiB
JavaScript
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'});
|
||
|
|
});
|