Files
safetynet/tests/broker-managed-proxy.test.mjs
T

173 lines
20 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import {generateKeyPairSync} from 'node:crypto';
import {createRequire} from 'node:module';
import {once} from 'node:events';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs';
const require=createRequire(import.meta.url),{createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs');
const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'});
async function fixture(t,configure=()=>{},scanner,enterpriseInspector,options={}) {
const received=[];let connections=0;
const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const c of req)chunks.push(c);received.push(Buffer.concat(chunks).toString());res.writeHead(201);res.end('accepted');});target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
const destination=`localhost:${target.address().port}`,p=defaultPolicy();p.assignment={deviceId:'device-1',groups:[]};p.dlp.rules=[{id:'company-secret',label:'Confidential',kind:'literal',value:'PROJECT SECRET',action:'block'}];configure(p);
const policyAuthority=await createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1});policyAuthority.accept(signPolicy(p,privateKey));
const audit=createSyntheticAudit(t);
const proxy=await createManagedProxy({policyAuthority,persistAudit:audit.persist,localInspector:scanner,enterpriseInspector,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,forward:({body,signal,maxResponseBytes})=>new Promise((resolve,reject)=>{
const request=https.request({host:'127.0.0.1',port:target.address().port,method:'POST',servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{'content-length':body.length}},res=>{const chunks=[];let bytes=0;res.on('data',chunk=>{bytes+=chunk.length;if(bytes>maxResponseBytes){request.destroy();reject(Error('bound'));}else chunks.push(chunk);});res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});request.on('error',reject);request.end(body);
}),deadlineMs:1000,authMode:'bearer',...options});const port=await proxy.listen();
t.after(async()=>{await proxy.close();target.closeAllConnections();await new Promise(r=>target.close(r));});
const send=(body,{path='/submit',headers={}}={})=>new Promise((resolve,reject)=>{
const req=http.request({host:'127.0.0.1',port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}});req.setTimeout(2000,()=>req.destroy(Error('deadline')));req.on('error',reject);req.on('connect',(response,socket)=>{if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;}const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let text='';secure.setTimeout(2000,()=>secure.destroy(Error('deadline')));secure.on('error',reject);secure.on('secureConnect',()=>{const extra=Object.entries(headers).map(([name,value])=>`${name}: ${value}\r\n`).join('');secure.write(`POST ${path} HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${extra}Connection: close\r\n\r\n`);secure.write(body);});secure.on('data',chunk=>text+=chunk);secure.on('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(text)?.[1]));});});req.end();
});return {send,received,policyAuthority,p,audit,get connections(){return connections;}};
}
test('signed company literal and credentials block before dial; public actualTLS delivery',async t=>{
const f=await fixture(t);
assert.equal(await f.send('PROJECT SECRET'),403);assert.equal(await f.send('sk-abcdefghijklmnopqrstuvwxyz123456'),403);assert.equal(f.connections,0);
assert.equal(await f.send('public document'),201);assert.deepEqual(f.received,['public document']);
});
test('signed semantic or YARA requirements cannot omit local scanner',async t=>{
const vector=Array(768).fill(0);vector[0]=1;
for(const feature of ['semantic','yara']) {
const f=await fixture(t,p=>{if(feature==='semantic'){p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'confidential',label:'Confidential',threshold:.9,prototype:vector}];}else p.security.yara.enabled=true;});
assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
}
});
test('binary upload requires complete local coverage and magic-byte policy applies first',async t=>{
const f=await fixture(t);assert.equal(await f.send(Buffer.from([0,1,2,255])),403);assert.equal(f.connections,0);
const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pe'],async()=>assert.fail('scanner must not run'));assert.equal(await blocked.send(Buffer.from('MZPUBLIC')),403);assert.equal(blocked.connections,0);
});
test('genuine signed policy update during local scan invalidates release',async t=>{
let entered,release;const started=new Promise(r=>entered=r),hold=new Promise(r=>release=r);
const f=await fixture(t,p=>p.security.yara.enabled=true,async()=>{entered();await hold;return {decision:'allow',coverage:{complete:true}};});
const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0);
});
test('wrong device and tampered signature cannot authorize listener creation',async()=>{
const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'}),p=defaultPolicy();p.assignment={deviceId:'device-2',groups:[]};
assert.throws(()=>authority.accept(signPolicy(p,privateKey)));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'});
const envelope=signPolicy(defaultPolicy(),privateKey);envelope.signature='AAAA';assert.throws(()=>authority.accept(envelope));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'});
});
test('mandatory ICAP cannot omit scanner or accept incomplete unbound evidence',async t=>{
const configure=p=>{p.enterprise.icap.enabled=true;p.enterprise.icap.url='icaps://scanner.example/scan';};
const missing=await fixture(t,configure);assert.equal(await missing.send('public'),403);assert.equal(missing.connections,0);
const incomplete=await fixture(t,configure,undefined,async()=>({decision:'allow',coverage:{complete:true},sha256:'0'.repeat(64),bytes:6}));assert.equal(await incomplete.send('public'),403);assert.equal(incomplete.connections,0);
});
test('partial local coverage and scanner mutation fail closed; destination blacklist applies',async t=>{
for(const inspector of [async()=>({decision:'allow',coverage:{complete:false}}),async bytes=>{bytes.fill(0);return {decision:'allow',coverage:{complete:true}};}]) {
const f=await fixture(t,p=>p.security.yara.enabled=true,inspector);assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
}
const f=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
});
test('decoded text representations receive independent local scans before release',async t=>{
const seen=[];
const f=await fixture(t,p=>p.security.yara.enabled=true,async bytes=>{
const text=bytes.toString();seen.push(text);return {decision:text==='hidden private meaning'?'block':'allow',coverage:{complete:true}};
});
assert.equal(await f.send('hidden%20private%20meaning'),403);assert.ok(seen.includes('hidden%20private%20meaning'));assert.ok(seen.includes('hidden private meaning'));assert.equal(f.connections,0);
});
test('ASCII PDF magic requires local parsed coverage even when raw UTF8 inspection allows',async t=>{
const document=Buffer.from('%PDF-1.4\n1 0 obj\n<< /Filter /ASCIIHexDecode >>\nstream\n50524F4A45435420534543524554>\nendstream\nendobj\n%%EOF');
const missing=await fixture(t);assert.equal(inspect(document,missing.p).decision,'allow');assert.equal(await missing.send(document),403);assert.equal(missing.connections,0);
let scanned=0;
const rejected=await fixture(t,()=>{},async bytes=>{scanned++;assert.deepEqual(bytes,document);return {decision:'block',coverage:{complete:true}};});assert.equal(await rejected.send(document),403);assert.equal(scanned,1);assert.equal(rejected.connections,0);
// Controlled scanner contract proof, not evidence of real PDF parsing.
const publicDocument=Buffer.from('%PDF-1.4\npublic test document\n%%EOF');
const allowed=await fixture(t,()=>{},async bytes=>{assert.deepEqual(bytes,publicDocument);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(publicDocument),201);assert.deepEqual(allowed.received,[publicDocument.toString()]);
});
test('custom header and Cookie corporate secrets block despite benign body',async t=>{
const f=await fixture(t);
assert.equal(await f.send('public',{headers:{'X-Note':'PROJECT SECRET'}}),403);
assert.equal(await f.send('public',{headers:{Cookie:'note=PROJECT SECRET'}}),403);
assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
});
test('signed semantic query requires independent decoded URL inspection',async t=>{
const seen=[];const vector=Array(768).fill(0);vector[0]=1;
const f=await fixture(t,p=>{p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'sensitive',label:'Sensitive',threshold:.9,prototype:vector}];},async bytes=>{const value=bytes.toString();seen.push(value);return {decision:value.includes('semantic query')?'block':'allow',coverage:{complete:true}};});
assert.equal(await f.send('public',{path:'/submit?note=semantic%20query'}),403);assert.ok(seen.some(value=>value.includes('semantic query')));assert.equal(f.connections,0);
});
test('multipart files receive independent magic policy and complete parsed document gate',async t=>{
const multipart=document=>Buffer.from(`--test-boundary\r\nContent-Disposition: form-data; name="note"\r\n\r\npublic text\r\n--test-boundary\r\nContent-Disposition: form-data; name="document"; filename="report.pdf"\r\nContent-Type: application/pdf\r\n\r\n${document}\r\n--test-boundary--\r\n`);
const headers={'Content-Type':'multipart/form-data; boundary=test-boundary'};
const document='%PDF-1.4\npublic sample\n%%EOF',body=multipart(document);
const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pdf']);assert.equal(await blocked.send(body,{headers}),403);assert.equal(blocked.connections,0);
const missing=await fixture(t);assert.equal(await missing.send(body,{headers}),403);assert.equal(missing.connections,0);
let parsed=0;
const allowed=await fixture(t,()=>{},async bytes=>{parsed++;assert.equal(bytes.toString(),document);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(body,{headers}),201);assert.equal(parsed,1);assert.deepEqual(allowed.received,[body.toString()]);
const rejected=await fixture(t,()=>{},async()=>({decision:'block',coverage:{complete:true}}));assert.equal(await rejected.send(multipart('%PDF-1.4\n50524F4A45435420534543524554>\n%%EOF'),{headers}),403);assert.equal(rejected.connections,0);
});
test('malformed multipart body cannot be released as ordinary printable text',async t=>{
const f=await fixture(t);assert.equal(await f.send('--boundary\r\ntruncated',{headers:{'Content-Type':'multipart/form-data; boundary=boundary'}}),403);assert.equal(f.connections,0);
});
test('durable allow receipt is required before any upstream dial',async t=>{
let entered,release,f;
const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{entered();await hold;return f.audit.persist(event);}});
const pending=f.send('public');await started;assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
release();assert.equal(await pending,201);assert.equal(f.audit.events().length,1);assert.equal(f.audit.events()[0].kind,'upload.authorized');
});
test('failed audit persistence blocks without replay and never logs protected input',async t=>{
let calls=0;
const f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async()=>{calls++;throw Error('PRIVATE_AUDIT_FAILURE_CONTENT');}});
assert.equal(await f.send('public'),403);assert.equal(calls,1);assert.equal(f.connections,0);
const blocked=await fixture(t);assert.equal(await blocked.send('PROJECT SECRET'),403);
const events=blocked.audit.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'company-secret');assert.equal(events[0].request.detectedType,'text');assert.equal(events[0].request.bytes,14);assert.ok(!JSON.stringify(events).includes('PROJECT SECRET'));assert.ok(!JSON.stringify(events).includes('sha256'));
});
test('signed update during durable commit blocks release after receipt, without retry',async t=>{
let entered,release,f,calls=0;
const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{calls++;entered();await hold;return f.audit.persist(event);}});
const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal(calls,2);
const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.notEqual(events[0].eventId,events[1].eventId);assert.equal(events[1].broker.reason,'broker-authority-expired');
});
test('nonsettling audit consumes shared capacity across timed-out CONNECT requests',async t=>{
let calls=0;
const f=await fixture(t,()=>{},undefined,undefined,{maxClients:1,deadlineMs:100,persistAudit:()=>{calls++;return new Promise(()=>{});}});
for(let index=0;index<4;index++)assert.equal(await f.send('public'),403);
assert.equal(calls,1);assert.equal(f.connections,0);
});
test('valid policy cannot create a managed listener without mandatory audit adapter',async()=>{
const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'});authority.accept(signPolicy(defaultPolicy(),privateKey));
await assert.rejects(createManagedProxy({policyAuthority:authority,resolveCertificate:()=>{},forward:()=>{}}),{code:'managed-audit-unavailable'});
});
test('genuine local block retains signed detector ID without allowing incomplete coverage',async t=>{
const document=Buffer.from('%PDF-1.4\npublic encoded fixture\n%%EOF');
const f=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'company-secret',coverage:{complete:false},privateDetails:'NEVER_SERIALIZE'}));
assert.equal(await f.send(document),403);assert.equal(f.connections,0);
const event=f.audit.events()[0];assert.equal(event.ruleId,'company-secret');assert.equal(event.kind,'upload.block');assert.ok(!JSON.stringify(event).includes('NEVER_SERIALIZE'));
const unknown=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'ARBITRARY_PRIVATE_RULE_TEXT',coverage:{complete:true}}));assert.equal(await unknown.send(document),403);assert.equal(unknown.audit.events()[0].ruleId,'inspection-incomplete');
const mutated=await fixture(t,()=>{},async bytes=>{bytes.fill(0);return {decision:'block',ruleId:'company-secret'};});assert.equal(await mutated.send(document),403);assert.equal(mutated.audit.events()[0].ruleId,'inspection-incomplete');
});
test('lifecycle upstream failure has one causal durable terminal event without retry',async t=>{
let attempts=0;const f=await fixture(t,undefined,undefined,undefined,{forward:async()=>{attempts++;throw Error('private upstream details');}});
assert.equal(await f.send('public'),502);assert.equal(attempts,1);assert.equal(f.connections,0);
const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[0].broker.phase,'request.authorized');assert.equal(events[1].broker.phase,'forward.fault');assert.equal(events[1].broker.reason,'broker-upstream-failed');assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(JSON.stringify(events).includes('private upstream'),false);
});
test('lifecycle CONNECT destination denial and ambiguous framing record before any target TCP',async t=>{
const denied=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await denied.send('public'),403);assert.equal(denied.connections,0);const event=denied.audit.events()[0];assert.equal(event.broker.phase,'connect.block');assert.equal(event.broker.reason,'broker-connect-denied');
const ambiguous=await fixture(t);await ambiguous.send('public',{headers:{'Transfer-Encoding':'chunked'}}).catch(()=>{});assert.equal(ambiguous.connections,0);const records=ambiguous.audit.events();assert.equal(records.length,1);assert.equal(records[0].broker.reason,'broker-framing-block');assert.equal(records[0].broker.phase,'request.block');
});
test('encoded document body/header/URL carriers hit actual signed fingerprint before target TCP',async t=>{
const {createHash}=await import('node:crypto'),document=Buffer.from('%PDF-1.4 Registered document'),fingerprint=createHash('sha256').update(document).digest('hex');
const f=await fixture(t,p=>p.dlp.rules=[{id:'encoded-fingerprint',label:'Synthetic',kind:'sha256',value:fingerprint,action:'block'}],async()=>assert.fail('registered fingerprint must block before local parser'));
const encoded=document.toString('base64'),uri='data:application/pdf;base64,'+encoded;
for(const body of [JSON.stringify({file:encoded}),JSON.stringify({file:encodeURIComponent(uri)}),JSON.stringify({file:Buffer.from(JSON.stringify({file:encoded})).toString('base64')})])assert.equal(await f.send(body,{headers:{'Content-Type':'application/json'}}),403);
assert.equal(await f.send('public',{headers:{'X-Document':uri}}),403);assert.equal(await f.send('public',{path:'/upload?file='+encodeURIComponent(uri)}),403);assert.equal(f.connections,0);assert.equal(f.audit.events().length,5);assert.ok(f.audit.events().every(event=>event.ruleId==='encoded-fingerprint'));
});
test('public encoded document must receive full local coverage while original outer JSON remains exact',async t=>{
const document=Buffer.from('%PDF-1.4 Public registered form'),seen=[];
const f=await fixture(t,undefined,async bytes=>{seen.push(Buffer.from(bytes));return {decision:'allow',coverage:{complete:true}};});
const outer=JSON.stringify({file:document.toString('base64')});assert.equal(await f.send(outer,{headers:{'Content-Type':'application/json'}}),201);assert.deepEqual(f.received,[outer]);assert.ok(seen.some(bytes=>bytes.equals(document)));
});
test('encoded vector SVG and nested carriers beyond the global depth budget are unscannable',async t=>{
const f=await fixture(t,undefined,async()=>assert.fail('unsupported SVG must not be counted as plain text coverage'));
assert.equal(await f.send(JSON.stringify({file:'data:image/svg+xml;base64,'+Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>').toString('base64')}),{headers:{'Content-Type':'application/json'}}),403);
let value='data:application/pdf;base64,'+Buffer.from('%PDF-1.4 public').toString('base64');for(let count=0;count<6;count++)value='data:application/json;base64,'+Buffer.from(JSON.stringify({file:value})).toString('base64');
assert.equal(await f.send(JSON.stringify({file:value}),{headers:{'Content-Type':'application/json'}}),403);assert.equal(f.connections,0);
});