157 lines
14 KiB
JavaScript
157 lines
14 KiB
JavaScript
// Explicit Windows integration: run with the verified bundled Node executable
|
|
// and SAFETYNET_NATIVE_BROKER_INSPECTION=1. Default unit suites skip this gate.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import {createRequire} from 'node:module';
|
|
import {createHash,generateKeyPairSync} from 'node:crypto';
|
|
import {execFileSync} from 'node:child_process';
|
|
import http from 'node:http';
|
|
import https from 'node:https';
|
|
import tls from 'node:tls';
|
|
import {once} from 'node:events';
|
|
import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs';
|
|
import {verifyBrokerRuntime} from '../scripts/provision-broker-runtime.mjs';
|
|
const require=createRequire(import.meta.url),{startInspector}=require('../apps/browser/inspection.cjs');
|
|
const {createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs');
|
|
const {createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs');
|
|
const {createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs');
|
|
const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
|
|
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
function pdf(text){
|
|
const stream=`BT /F1 12 Tf 72 720 Td <${Buffer.from(text).toString('hex')}> Tj ET`;
|
|
const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`];
|
|
let output='%PDF-1.4\n';const offsets=[0];
|
|
objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(output));output+=`${index+1} 0 obj\n${object}\nendobj\n`;});
|
|
const xref=Buffer.byteLength(output);output+=`xref\n0 6\n0000000000 65535 f \n${offsets.slice(1).map(offset=>String(offset).padStart(10,'0')+' 00000 n \n').join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
return Buffer.from(output);
|
|
}
|
|
function inspectorChildren(){
|
|
const script=`@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*apps*inspector*server.py*' } | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`;
|
|
const output=execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',script],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();
|
|
return output?[].concat(JSON.parse(output)):[];
|
|
}
|
|
test('bundled Windows Node managed TLS broker uses real contained document inspection before dial',{
|
|
skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_BROKER_INSPECTION!=='1',timeout:120000
|
|
},async t=>{
|
|
const root=path.resolve('.'),proofDirectory=path.join(root,'artifacts/broker-native-inspection');await fs.mkdir(proofDirectory,{recursive:true});
|
|
const proof={startedAt:new Date().toISOString(),outcome:'running',diagnosticsEnabled:false,scans:[],office:[]};
|
|
let inspector,proxy,target;const started=performance.now();
|
|
try{
|
|
const runtime=await verifyBrokerRuntime(path.join(root,'.runtime/windows/broker'));
|
|
assert.equal(path.resolve(process.execPath).toLowerCase(),path.join(root,'.runtime/windows/broker/node.exe').toLowerCase(),'Run this gate with the bundled broker Node executable');
|
|
proof.runtime={version:runtime.version,executableSha256:runtime.files.find(file=>file.path==='node.exe').sha256};
|
|
inspector=startInspector(root,false);assert.ok(inspector,'Contained inspector runtime unavailable');await inspector.waitReady();
|
|
const children=inspectorChildren();assert.equal(children.length,1,'Expected one owned contained inspector parent');proof.inspectorOwnedPids=children;
|
|
// startInspector only resolves readiness after validating Job Object plus
|
|
// zero-network-capability AppContainer/private-pipe/read-only containment.
|
|
proof.containmentStartupValidated=true;
|
|
const key=await fs.readFile(new URL('./fixtures/icap/key.pem',import.meta.url)),cert=await fs.readFile(new URL('./fixtures/icap/cert.pem',import.meta.url));
|
|
const received=[];let connections=0;
|
|
target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);const body=Buffer.concat(chunks);received.push({sha256:hash(body),bytes:body.length});res.end('public receiver acknowledgment');});
|
|
target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
|
|
const port=target.address().port,destination=`localhost:${port}`;
|
|
const keys=generateKeyPairSync('ed25519'),policy=defaultPolicy();policy.assignment={deviceId:'native-broker-device',groups:[]};
|
|
policy.dlp.rules=[{id:'synthetic-confidential-document',label:'Synthetic confidential',kind:'literal',value:'SYNTHETIC INTERNAL BLUEPRINT',action:'block'}];
|
|
assert.equal(policy.diagnostics.enabled,false);
|
|
const authority=await createPolicyAuthority({deviceId:'native-broker-device',publicKey:keys.publicKey.export({type:'spki',format:'pem'})});
|
|
authority.accept(signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'})));
|
|
const snapshot=authority.snapshot();
|
|
const forward=createHttpsForwarder({authority:snapshot.token,current:authority.current,resolveRoute:async value=>({approved:value.hostname==='localhost'&&value.port===port,address:'127.0.0.1',port:value.port}),ca:cert,deadlineMs:45000,maxResponseBytes:Math.min(16777216,policy.fileTransfers.download.maxBytes)});
|
|
const durableAudit=createSyntheticAudit(t);
|
|
proxy=await createManagedProxy({policyAuthority:authority,authMode:'bearer',persistAudit:durableAudit.persist,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,
|
|
localInspector:async(bytes,signedPolicy)=>{const before=performance.now(),result=await inspector.inspect(bytes,signedPolicy);proof.scans.push({durationMs:performance.now()-before,decision:result.decision,complete:result.coverage.complete,contentType:result.contentType,matchedSyntheticRule:result.ruleId==='synthetic-confidential-document'});return result;},forward,deadlineMs:45000});
|
|
const proxyPort=await proxy.listen();
|
|
const send=(body,mime='application/pdf')=>new Promise((resolve,reject)=>{
|
|
const request=http.request({host:'127.0.0.1',port:proxyPort,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}});
|
|
request.setTimeout(55000,()=>request.destroy(new Error('native-test-deadline')));request.once('error',reject);
|
|
request.once('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 reply='';
|
|
secure.setTimeout(55000,()=>secure.destroy(new Error('native-test-deadline')));secure.once('error',reject);
|
|
secure.once('secureConnect',()=>secure.write(Buffer.concat([Buffer.from(`POST /upload HTTP/1.1\r\nHost: ${destination}\r\nContent-Type: ${mime}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`),body])));
|
|
secure.on('data',chunk=>reply+=chunk.toString());secure.once('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(reply)?.[1]));});
|
|
});request.end();
|
|
});
|
|
const secret=pdf(policy.dlp.rules[0].value),publicDocument=pdf('Public example report');
|
|
assert.equal(inspect(secret,policy).decision,'allow','Encoded PDF must require genuine parsing, not raw literal detection');
|
|
assert.equal(await send(secret),403);assert.equal(connections,0);assert.equal(received.length,0);
|
|
proof.secret={status:403,upstreamConnections:connections,receivedBytes:0};
|
|
assert.equal(await send(publicDocument),200);assert.deepEqual(received,[{sha256:hash(publicDocument),bytes:publicDocument.length}]);
|
|
assert.ok(proof.scans.some(scan=>scan.decision==='block'&&scan.contentType==='pdf'&&scan.matchedSyntheticRule));
|
|
assert.ok(proof.scans.some(scan=>scan.decision==='allow'&&scan.complete&&scan.contentType==='pdf'));
|
|
proof.public={status:200,upstreamConnections:connections,...received[0]};
|
|
const officeRoot=path.join(root,'artifacts/office-coverage-fixtures');
|
|
// Only synthetic fixtures are modified by stdlib ZIP/XML; no classifier,
|
|
// document parser or OCR result is substituted in the actual broker gate.
|
|
const mutate=[
|
|
'import sys,zipfile,pathlib,xml.etree.ElementTree as ET',
|
|
'root=pathlib.Path(sys.argv[1]); marker=sys.argv[2]',
|
|
"for source,destination,member in [('public-generated.docx','secret-metadata.docx','docProps/core.xml'),('public-generated.xlsx','secret-hidden.xlsx','xl/worksheets/sheet2.xml')]:",
|
|
' with zipfile.ZipFile(root/source) as archive:',
|
|
' records=[(entry,archive.read(entry)) for entry in archive.infolist()]',
|
|
" if source.endswith('.xlsx'):",
|
|
" workbook=ET.fromstring(archive.read('xl/workbook.xml'))",
|
|
" sheets=[node for node in workbook.iter() if node.tag.endswith('}sheet')]",
|
|
" assert len(sheets)==2 and sheets[1].attrib.get('state')=='hidden'",
|
|
" with zipfile.ZipFile(root/destination,'w') as output:",
|
|
' changed=False',
|
|
' for entry,data in records:',
|
|
' if entry.filename==member:',
|
|
' document=ET.fromstring(data)',
|
|
" if source.endswith('.docx'):",
|
|
" title=document.find('{http://purl.org/dc/elements/1.1/}title')",
|
|
" if title is None: title=ET.SubElement(document,'{http://purl.org/dc/elements/1.1/}title')",
|
|
' title.text=marker',
|
|
' else:',
|
|
" namespace='{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'",
|
|
" sheet=document.find(namespace+'sheetData')",
|
|
" if sheet is None: raise RuntimeError('Synthetic sheet data absent')",
|
|
" row=ET.SubElement(sheet,namespace+'row',{'r':'1'})",
|
|
" cell=ET.SubElement(row,namespace+'c',{'r':'A1','t':'inlineStr'})",
|
|
" value=ET.SubElement(ET.SubElement(cell,namespace+'is'),namespace+'t'); value.text=marker",
|
|
" data=ET.tostring(document,encoding='utf-8',xml_declaration=True);changed=True",
|
|
' output.writestr(entry,data)',
|
|
" if not changed: raise RuntimeError('Synthetic fixture target part absent')",
|
|
''
|
|
].join('\n');
|
|
execFileSync(path.join(root,'.runtime/windows/python/python.exe'),['-c',mutate,officeRoot,policy.dlp.rules[0].value],{windowsHide:true,stdio:'pipe',timeout:10000});
|
|
const expectedPublic=[received[0]];
|
|
for(const [label,name,mime] of [
|
|
['docx','public-generated.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
['xlsx','public-generated.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){
|
|
const bytes=await fs.readFile(path.join(officeRoot,name)),firstScan=proof.scans.length;
|
|
assert.equal(await send(bytes,mime),200);
|
|
const observed=proof.scans.slice(firstScan);assert.ok(observed.some(scan=>scan.contentType==='office'&&scan.decision==='allow'&&scan.complete));
|
|
expectedPublic.push({sha256:hash(bytes),bytes:bytes.length});proof.office.push({case:label+'-public',status:200,complete:true,sha256:hash(bytes),bytes:bytes.length});
|
|
}
|
|
for(const [label,name,mime] of [
|
|
['docx-metadata','secret-metadata.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
['xlsx-hidden-sheet','secret-hidden.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){
|
|
const before=connections,firstScan=proof.scans.length,bytes=await fs.readFile(path.join(officeRoot,name));
|
|
assert.equal(await send(bytes,mime),403);assert.equal(connections,before);
|
|
assert.ok(proof.scans.slice(firstScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&scan.matchedSyntheticRule));
|
|
proof.office.push({case:label,status:403,matchedSyntheticRule:true,newUpstreamConnections:0});
|
|
}
|
|
const complex=await fs.readFile(path.join(officeRoot,'public-generated.pptx')),beforeComplex=connections,firstComplexScan=proof.scans.length;
|
|
assert.equal(await send(complex,'application/vnd.openxmlformats-officedocument.presentationml.presentation'),403);assert.equal(connections,beforeComplex);
|
|
assert.ok(proof.scans.slice(firstComplexScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&!scan.complete));
|
|
proof.office.push({case:'unsupported-complex-pptx',status:403,complete:false,newUpstreamConnections:0});
|
|
assert.deepEqual(received,expectedPublic);
|
|
const auditEvents=durableAudit.events();
|
|
assert.equal(auditEvents.filter(event=>event.kind==='upload.authorized').length,3);
|
|
assert.equal(auditEvents.filter(event=>event.kind==='upload.block'&&event.ruleId==='synthetic-confidential-document').length,3);
|
|
assert.equal(auditEvents.length,7);
|
|
proof.audit={persistedEvents:auditEvents.length,authorizedEvents:3,matchedSecretBlocks:3,syntheticMetadataFsynced:true};
|
|
proof.outcome='passed';
|
|
}catch(error){proof.outcome='failed';proof.errorType=error.name;throw error;}
|
|
finally{
|
|
if(proxy)await proxy.close();if(target){target.closeAllConnections();await new Promise(resolve=>target.close(resolve));}
|
|
if(inspector){inspector.stop();const deadline=Date.now()+10000;while(inspectorChildren().length&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,100));proof.ownedInspectorParentsRemaining=inspectorChildren().length;}
|
|
proof.durationMs=performance.now()-started;await fs.writeFile(path.join(proofDirectory,`proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify(proof,null,2)+'\n');
|
|
await fs.writeFile(path.join(proofDirectory,`office-proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify({startedAt:proof.startedAt,outcome:proof.outcome,diagnosticsEnabled:false,runtime:proof.runtime,office:proof.office,scans:proof.scans.filter(scan=>scan.contentType==='office'),audit:proof.audit,ownedInspectorParentsRemaining:proof.ownedInspectorParentsRemaining},null,2)+'\n');
|
|
assert.equal(proof.ownedInspectorParentsRemaining??0,0,'Owned inspector parent did not terminate');
|
|
}
|
|
});
|