Files
safetynet/scripts/fullmain-wt-native.cjs
T

14 lines
10 KiB
JavaScript
Raw Normal View History

'use strict';
const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto');
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();const mode=process.env.SAFETYNET_WT_MODE,target=process.env.SAFETYNET_WT_TARGET,hashBytes=Array.from(Buffer.from(process.env.SAFETYNET_WT_HASH,'hex')),origin=process.env.SAFETYNET_SMOKE_ORIGIN;let actualDiagnostics,nativeWTCallbacks=0;
if(mode==='managed'){const partition=session.fromPartition.bind(session),seenSessions=new WeakSet();session.fromPartition=(...args)=>{const current=partition(...args);if(!seenSessions.has(current)){seenSessions.add(current);const register=current.webRequest.onBeforeRequest.bind(current.webRequest);current.webRequest.onBeforeRequest=(...values)=>{const index=values.length-1,listener=values[index];if(typeof listener==='function')values[index]=(details,callback)=>{if(details.url?.startsWith(target))nativeWTCallbacks++;return listener(details,callback);};return register(...values);};}return current;};const diagnosticModule=require('../apps/browser/diagnostics.cjs'),createDiagnostics=diagnosticModule.createDiagnostics;diagnosticModule.createDiagnostics=(...args)=>{const value=createDiagnostics(...args);actualDiagnostics=value;return value;};require('../apps/browser/main.cjs');}else app.enableSandbox();
// Executed in the real Chromium Window and dedicated Worker, without a shim.
/* global WebTransport */
async function attemptWT(url,hash){const result={api:typeof WebTransport==='function',connected:false,datagramWritten:false,streamWritten:false,errorClass:null};let transport;try{transport=new WebTransport(url,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('timeout')),5000))]);result.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'),datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();result.datagramWritten=true;const stream=await transport.createUnidirectionalStream(),writer=stream.getWriter();await writer.write(bytes);await writer.close();result.streamWritten=true;await new Promise(r=>setTimeout(r,350));}catch(error){result.errorClass=['Error','TypeError','WebTransportError','NetworkError'].includes(error.name)?error.name:'OtherError';}finally{transport?.close();}return result;}
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Full main WebTransport fixture timeout');}
async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};onmessage=async e=>{postMessage(await attempt(e.data.url,e.data.hash));close();};`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};worker.postMessage({url:${JSON.stringify(target+'/worker')},hash:${JSON.stringify(hashBytes)}});})`);return {window,worker};}
app.whenReady().then(async()=>{let page,win,stage='startup';try{const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;if(mode==='baseline'){session.defaultSession.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});let callbacks=0;session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{callbacks++;callback({cancel:true});});win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});page=win.webContents;await win.loadURL(origin+'/workspace');const realms=await attemptRealms(page);assert.equal(callbacks,0);for(const realm of [realms.window,realms.worker])assert.ok(realm.connected&&realm.datagramWritten&&realm.streamWritten);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualElectron:true,mode,realms,webRequestCallbacks:callbacks,electron:process.versions.electron,chromium:process.versions.chrome},null,2));win.destroy();app.exit(0);return;}
win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay');
const change=async proxy=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.proxy=proxy;const proposal=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposal.status,202);const review=await proposal.json();const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');};stage='reviewed direct upstream policy';await change({mode:'direct'});await test('reviewed DIRECT upstream retains relay');stage='reviewed PAC direct upstream policy';await change({mode:'pac_script',pacUrl:origin+'/direct.pac'});await test('reviewed PAC DIRECT upstream retains relay');
stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),unsupportedTransportAuditGap:'Chromium WebTransport bypasses Electron webRequest; receiver absence is native containment evidence, not an attributed per-attempt DLP audit event.',diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Full enrolled main WebTransport protection passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
}catch(error){fs.writeFileSync(path.resolve('artifacts/fullmain-wt-native-error.json'),JSON.stringify({mode,stage,name:error.name},null,2));console.error('Full main WebTransport failed at '+stage+': '+error.name);if(mode==='managed'&&page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});