34 lines
15 KiB
JavaScript
34 lines
15 KiB
JavaScript
'use strict';
|
|
// All wrappers observe the real native guards; they never replace enforcement.
|
|
const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),https=require('node:https'),{X509Certificate}=require('node:crypto');
|
|
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();
|
|
const origin=process.env.SAFETYNET_SMOKE_ORIGIN,forbiddenOrigin=process.env.SAFETYNET_SMOKE_FORBIDDEN_ORIGIN,fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;
|
|
const nativeSessions=new WeakMap(),records=[],requests=[],fromPartition=session.fromPartition.bind(session);
|
|
session.fromPartition=(...args)=>{const s=fromPartition(...args);if(nativeSessions.has(s))return s;const record={index:records.length,partitionOwned:typeof args[0]==='string'&&args[0].startsWith('safetynet-agent-'),hooks:[],firstRequestHooks:null};records.push(record);nativeSessions.set(s,record);s.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=['127.0.0.1','127.0.0.2'].includes(req.hostname)&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});
|
|
for(const name of ['onBeforeRequest','onBeforeSendHeaders','onBeforeRedirect','onCompleted','onErrorOccurred']){const original=s.webRequest[name].bind(s.webRequest);s.webRequest[name]=(...values)=>{const index=values.length-1,listener=values[index];if(typeof listener==='function'){record.hooks.push(name);values[index]=(...eventArgs)=>{const d=eventArgs[0];if(name==='onBeforeRequest'&&d.url?.startsWith(origin)){record.firstRequestHooks??=[...record.hooks];if(d.url.includes('/worker/'))requests.push({nativeSession:record.index,requestId:d.id,hasWebContents:Number.isInteger(d.webContentsId)&&d.webContentsId>0,hasFrame:!!d.frame,method:d.method,bytes:(d.uploadData||[]).reduce((n,v)=>n+(v.bytes?.length||0),0),sameInitiator:d.initiator===origin});}return listener(...eventArgs);};}return original(...values);};}
|
|
for(const name of ['setPermissionRequestHandler','setPermissionCheckHandler','registerPreloadScript'])if(typeof s[name]==='function'){const original=s[name].bind(s);s[name]=(...values)=>{record.hooks.push(name);return original(...values);};}
|
|
const proxy=s.setProxy.bind(s);s.setProxy=async(...values)=>{const result=await proxy(...values);record.hooks.push('proxyReady');return result;};return s;};
|
|
const localWitnesses=[],inspection=require('../apps/browser/inspection.cjs'),startInspector=inspection.startInspector;inspection.startInspector=(...args)=>{const client=startInspector(...args),inspect=client.inspect.bind(client);client.inspect=async(bytes,policy)=>{const result=await inspect(bytes,policy);localWitnesses.push({decision:result.decision,ruleId:result.ruleId==='worker-yara-fixture'?'worker-yara-fixture':null,complete:result.coverage?.complete===true});return result;};return client;};
|
|
require('../apps/browser/main.cjs');
|
|
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'};
|
|
async function until(fn){for(let i=0;i<250;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native worker fixture timeout');}
|
|
const getFacts=()=>new Promise((resolve,reject)=>{const request=https.get(origin+'/facts',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(JSON.parse(body)));});request.on('error',reject);});
|
|
app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;},overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const human=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(human);await until(()=>!human.isLoading()&&human.getURL()===origin+'/app');
|
|
const connections={a:await ui('agentConnection','worker-a'),b:await ui('agentConnection','worker-b')};async function call(actor,action,args={}){const requestFile=path.join(app.getPath('userData'),'worker-request-'+actor+'.json');fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connections[actor].credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}
|
|
const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.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;});};
|
|
stage='owned native tab creation';const owned={};for(const actor of ['a','b']){const response=await call(actor,'tabs:create',{url:origin+'/seed-'+actor});assert.equal(response.exit,0,'Owned native creation refused');assert.ok(response.result.sessionId);const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/seed-'+actor);owned[actor]={id:response.result.sessionId,page,session:page.session,record:nativeSessions.get(page.session)};assert.ok(owned[actor].record?.partitionOwned,'Actor uses immutable owned partition');assert.equal((await call(actor,'navigate',{sessionId:owned[actor].id,url:origin+'/app'})).exit,0);await until(()=>!page.isLoading()&&page.getURL()===origin+'/app');}
|
|
assert.notEqual(owned.a.session,owned.b.session);assert.notEqual(owned.a.session,human.session);for(const actor of ['a','b']){const cookies=await owned[actor].session.cookies.get({url:origin,name:'worker-session'});assert.equal(cookies.length,1);assert.equal(cookies[0].value,actor.toUpperCase());const installed=owned[actor].record.firstRequestHooks;for(const hook of ['onBeforeRequest','onBeforeSendHeaders','onBeforeRedirect','onCompleted','onErrorOccurred','setPermissionRequestHandler','setPermissionCheckHandler','registerPreloadScript','proxyReady'])assert.ok(installed.includes(hook),'Missing guard before first native request: '+hook);}
|
|
stage='cross actor control denied';assert.equal((await call('b','read',{sessionId:owned.a.id})).exit,1);assert.equal((await call('b','navigate',{sessionId:owned.a.id,url:origin+'/app'})).exit,1);
|
|
const register=page=>page.executeJavaScript(`(async()=>{try{await navigator.serviceWorker.register('/sw.js',{scope:'/'});await Promise.race([navigator.serviceWorker.ready,new Promise((_,r)=>setTimeout(()=>r(Error('deadline')),5000))]);if(!navigator.serviceWorker.controller)await new Promise(r=>{const t=setTimeout(r,2000);navigator.serviceWorker.addEventListener('controllerchange',()=>{clearTimeout(t);r();},{once:true});});return !!navigator.serviceWorker.controller;}catch{return false;}})()`);
|
|
const send=(page,url,body)=>page.executeJavaScript(`new Promise(resolve=>{const worker=navigator.serviceWorker.controller;if(!worker){resolve({ok:false});return;}const c=new MessageChannel(),t=setTimeout(()=>resolve({ok:false}),5000);c.port1.onmessage=e=>{clearTimeout(t);resolve({ok:e.data.ok===true});};worker.postMessage(${JSON.stringify({url,body})},[c.port2]);})`);
|
|
stage='background gate defaults off';if(await register(owned.a.page))assert.equal((await send(owned.a.page,origin+'/worker/a','public-worker-message')).ok,false);assert.equal((await getFacts()).facts.publicA,0);
|
|
stage='independent background policy enable';await change(p=>p.browser.allowAgentBackgroundWorkers=true);for(const actor of ['a','b']){await until(()=>!owned[actor].page.isLoading());assert.equal(await register(owned[actor].page),true,'Owned worker registration unavailable');}
|
|
stage='both native workers public requests allowed';for(const actor of ['a','b'])assert.equal((await send(owned[actor].page,origin+'/worker/'+actor,'public-worker-message')).ok,true);const workerRequests=requests.filter(r=>!r.hasWebContents&&!r.hasFrame);assert.ok(workerRequests.some(r=>r.nativeSession===owned.a.record.index));assert.ok(workerRequests.some(r=>r.nativeSession===owned.b.record.index));
|
|
stage='core exact body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','CORE_COMPANY_WORKER_LITERAL')).ok,false);stage='real local YARA body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','YARA_WORKER_ONLY_SECRET')).ok,false);assert.ok(localWitnesses.some(w=>w.decision==='block'&&w.ruleId==='worker-yara-fixture'));stage='real ICAP body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','ICAP_WORKER_ONLY_SECRET')).ok,false);stage='worker destination outside grant denied';assert.equal((await send(owned.a.page,forbiddenOrigin+'/forbidden','public-worker-message')).ok,false);
|
|
stage='human worker remains ownerless denied';const humanRegistered=await register(human);if(humanRegistered)assert.equal((await send(human,forbiddenOrigin+'/human','public-worker-message')).ok,false);
|
|
stage='scope revoke denies running owned worker';await change(p=>p.agents.grants.find(g=>g.id==='worker-a').scopes=p.agents.grants.find(g=>g.id==='worker-a').scopes.filter(s=>s!=='network:background'));if(!owned.a.page.isDestroyed())assert.equal((await send(owned.a.page,origin+'/worker/a','public-worker-message')).ok,false);let staleAllowed=false;try{const response=await owned.a.session.fetch(origin+'/worker/a',{method:'POST',body:'public-worker-message'});staleAllowed=response.ok;}catch{}assert.equal(staleAllowed,false);assert.equal((await send(owned.b.page,origin+'/worker/b','public-worker-message')).ok,true);const bCookie=await owned.b.session.cookies.get({url:origin,name:'worker-session'});assert.equal(bCookie.length,1);assert.equal(bCookie[0].value,'B');
|
|
stage='independent receiver validates absence';const receiver=await getFacts();assert.equal(receiver.facts.publicA,1);assert.equal(receiver.facts.publicB,2);for(const name of ['core','yara','icap','forbidden'])assert.equal(receiver.facts[name],0);assert.ok(receiver.avFacts.workerScans>=3);assert.equal(receiver.avFacts.blocks,1);
|
|
stage='actual worker observability';await wait(1500);const events=[];let after=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${after}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));for(const row of rows){assert.ok(!/CORE_COMPANY_WORKER_LITERAL|YARA_WORKER_ONLY_SECRET|ICAP_WORKER_ONLY_SECRET|worker-session=/.test(JSON.stringify(row)));events.push(row);}if(rows.length<1000)break;const next=Number(response.headers.get('x-safetynet-next-cursor'));assert.ok(next>after);after=next;}const workerEvents=events.filter(e=>e.safetynet?.agent?.action==='network:background'&&e.trace?.id);for(const principal of ['worker-a','worker-b'])assert.ok(workerEvents.some(e=>e.safetynet.agent.principal_id===principal&&e.safetynet.agent.credential_generation===1&&e.safetynet.agent.outcome==='authorized'),'Missing durable native worker owner');assert.ok(workerEvents.some(e=>e.safetynet.agent.principal_id==='worker-a'&&e.safetynet.agent.outcome==='denied'),'Revoked worker denial missing owner attribution');const traced=workerEvents.filter(e=>e.safetynet.agent.outcome==='authorized');let trace,selectedTraceId;for(const event of traced.reverse()){const candidate=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();if(candidate.spans?.some(s=>s.name==='browser.request')&&candidate.spans.some(s=>s.name==='browser.inspect.local')){trace=candidate;selectedTraceId=event.trace.id;break;}}assert.ok(trace,'Native worker trace absent');assert.ok(trace.spans.some(s=>s.name==='browser.worker'),'Worker ownership stage absent');const spanMap=new Map(trace.spans.map(s=>[s.spanId,s]));const workerSpan=trace.spans.find(s=>s.name==='browser.worker');let ancestor=workerSpan,parented=false;const seen=new Set();while(ancestor&&!seen.has(ancestor.spanId)){if(ancestor.name==='browser.request'){parented=true;break;}seen.add(ancestor.spanId);ancestor=spanMap.get(ancestor.parentSpanId);}assert.ok(parented,'Native worker authorization is not correlated beneath browser.request');assert.ok(!/CORE_COMPANY_WORKER_LITERAL|YARA_WORKER_ONLY_SECRET|ICAP_WORKER_ONLY_SECRET/.test(JSON.stringify(trace)));const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();
|
|
fs.writeFileSync(path.resolve('artifacts/agent-worker-ownership-native-proof.json'),JSON.stringify({actualElectron:true,exactPinnedTLS:true,independentlyPublished:true,immutableNativePartitions:true,cookiesIsolated:true,crossActorAccessDenied:true,hooksInstalledBeforeFirstRequest:true,backgroundDefaultDenied:true,workerNativeAttribution:workerRequests,humanRegistered,humanBackgroundDenied:true,coreBodyBlocked:true,actualYARABlocked:true,actualICAPBlocked:true,forbiddenHostDenied:true,existingWorkerScopeRevocationEnforced:true,staleNativeSessionDenied:true,otherActorCookiePreserved:true,receiver,records,localWitnesses,traceId:selectedTraceId,workerAuditFacts:workerEvents.map(e=>({action:e.safetynet.agent.action,outcome:e.safetynet.agent.outcome,principal:e.safetynet.agent.principal_id,generation:e.safetynet.agent.credential_generation,traceId:e.trace.id})),stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Native agent worker ownership proof passed');for(const page of pages)if(!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
|
}catch(error){fs.writeFileSync(path.resolve('artifacts/agent-worker-ownership-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,records,requests,localWitnesses},null,2));console.error('Native worker proof failed at '+stage+': '+error.name);for(const page of pages)if(!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|