23 lines
15 KiB
JavaScript
23 lines
15 KiB
JavaScript
'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 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({png:Buffer.isBuffer(bytes)&&bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])),decision:result.decision,contentType:result.contentType,complete:result.coverage?.complete===true,confidentialRule:result.ruleId==='visual-confidential-fixture'});return result;};return client;};let replyGate;const control=require('../apps/browser/agent-control.cjs'),startControl=control.startAgentControl;control.startAgentControl=options=>startControl({...options,audit:async event=>{await options.audit(event);if(event.action==='screenshot'&&event.outcome==='completed'&&replyGate){const gate=replyGate;replyGate=null;gate.enter();await gate.wait;}}});require('../apps/browser/main.cjs');
|
|
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,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 visual fixture timeout');}
|
|
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 fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));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();stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/public');const a=await ui('agentConnection','visual-a'),b=await ui('agentConnection','visual-b'),requestFile=path.join(app.getPath('userData'),'visual-request.json');async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.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 sessionId=(await call(a,'sessions')).result[0].id;
|
|
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 approval=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(approval.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});};
|
|
stage='default disabled';assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='independently approved scope';await change(p=>{p.agents.scopes.push('screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes.push('screenshot');});await until(()=>!page.isLoading());win.focus();page.focus();await wait(150);
|
|
stage='public viewport approved';const captured=await call(a,'screenshot',{sessionId});assert.equal(captured.exit,0,'Public screenshot export refused');assert.deepEqual(Object.keys(captured.result).sort(),['data','height','mime','width']);assert.equal(captured.result.mime,'image/png');const png=Buffer.from(captured.result.data,'base64');assert.equal(png.toString('base64'),captured.result.data);assert.ok(png.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])));assert.equal(png.readUInt32BE(16),captured.result.width);assert.equal(png.readUInt32BE(20),captured.result.height);assert.ok(png.length<=2097152);fs.writeFileSync(path.resolve('artifacts/agent-visual-public-viewport.png'),png);png.fill(0);captured.result.data='';
|
|
stage='other actor denied';assert.equal((await call(b,'screenshot',{sessionId})).exit,1);stage='reject arbitrary screenshot parameters';assert.equal((await call(a,'screenshot',{sessionId,clip:{x:0,y:0,width:20,height:20}})).exit,1);
|
|
const navigate=async route=>{await ui('navigate',origin+route);await until(()=>!page.isLoading()&&page.getURL()===origin+route);win.focus();page.focus();await wait(150);};
|
|
stage='canvas confidential text OCR blocks';await navigate('/canvas');assert.equal(await page.executeJavaScript("document.body.innerText.includes('CLASSIFIED ORCHID DOCUMENT')"),false);assert.equal((await call(a,'screenshot',{sessionId})).exit,1);assert.ok(localWitnesses.some(w=>w.png&&w.contentType==='image'&&w.decision==='block'&&w.confidentialRule));
|
|
stage='mandatory actual PNG AV blocks';await navigate('/av-block');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='offscreen credential control denied';await navigate('/sensitive');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='closed shadow credential control denied';await navigate('/closed-shadow');assert.equal((await call(a,'screenshot',{sessionId})).exit,1);stage='open public shadow allowed';await navigate('/open-shadow');const shadowExport=await call(a,'screenshot',{sessionId});assert.equal(shadowExport.exit,0);shadowExport.result.data='';stage='child frames fail closed';await navigate('/frames');assert.ok(page.mainFrame.frames.length>0);assert.equal((await call(a,'screenshot',{sessionId})).exit,1);
|
|
async function delayedCapture(effect){let entered,release;const started=new Promise(r=>entered=r),gate=new Promise(r=>release=r),api=page.debugger,original=api.sendCommand.bind(api);api.sendCommand=async(...args)=>{const result=await original(...args);if(args[0]==='Page.captureScreenshot'){entered();await gate;}return result;};try{const pending=call(a,'screenshot',{sessionId});await Promise.race([started,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native screenshot did not reach capture boundary')),10000))]);await effect();release();assert.equal((await pending).exit,1);}finally{release();api.sendCommand=original;}}
|
|
stage='foreground race denies release';await navigate('/public');let otherId;await delayedCapture(async()=>{const state=await ui('createTab',{url:origin+'/public'});otherId=state.activeTabId;const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;if(other)pages.push(other);});await ui('activateTab',sessionId);await ui('closeTab',otherId);win.focus();page.focus();
|
|
stage='open shadow mutation and revert denies release';await navigate('/open-shadow');await delayedCapture(()=>page.executeJavaScript("const p=document.getElementById('host').shadowRoot.querySelector('p'),old=p.textContent;p.textContent='CLASSIFIED ORCHID DOCUMENT';p.textContent=old"));
|
|
async function delayedReply(effect){let enter,release;const entered=new Promise(r=>enter=r),waitForRelease=new Promise(r=>release=r);replyGate={enter,wait:waitForRelease};try{const pending=call(a,'screenshot',{sessionId});await Promise.race([entered,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native screenshot did not reach completed audit boundary')),30000))]);await effect();release();const result=await pending;assert.equal(result.exit,1);assert.equal(result.result,undefined);}finally{release();replyGate=null;}}
|
|
stage='late policy classification blocks inspected image';await navigate('/public');await delayedReply(async()=>{await change(p=>{p.dlp.rules.push({id:'late-visual-classification',label:'Synthetic late visual classification',kind:'literal',value:'Public approved visual page',action:'block'});});const p=(await overview()).policy;assert.ok(p.agents.scopes.includes('screenshot'));assert.ok(p.agents.grants.find(g=>g.id==='visual-a').enabled&&p.agents.grants.find(g=>g.id==='visual-a').scopes.includes('screenshot'));});await change(p=>p.dlp.rules=p.dlp.rules.filter(r=>r.id!=='late-visual-classification'));await navigate('/public');
|
|
stage='late foreground change blocks inspected image';let lateOther;await delayedReply(async()=>{const state=await ui('createTab',{url:origin+'/public'});lateOther=state.activeTabId;const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;if(other)pages.push(other);});await ui('activateTab',sessionId);await ui('closeTab',lateOther);win.focus();page.focus();
|
|
stage='policy race denies release';await delayedCapture(()=>change(p=>{p.agents.scopes=p.agents.scopes.filter(s=>s!=='screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes=['sessions'];}));assert.equal((await call(a,'screenshot',{sessionId})).exit,1);await change(p=>{p.agents.scopes.push('screenshot');p.agents.grants.find(g=>g.id==='visual-a').scopes.push('screenshot');});await until(()=>!page.isLoading());stage='target revoke denies release';await change(p=>{p.agents.grants.find(g=>g.id==='visual-a').enabled=false;});assert.equal((await call(a,'screenshot',{sessionId})).exit,1);
|
|
stage='actual 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(!JSON.stringify(row).includes('CLASSIFIED ORCHID DOCUMENT'));assert.ok(!JSON.stringify(row).includes('synthetic-hidden-field'));assert.ok(!JSON.stringify(row).includes('synthetic-closed-shadow-password'));events.push(row);}const next=Number(response.headers.get('x-safetynet-next-cursor'));if(rows.length<1000)break;assert.ok(next>after);after=next;}const event=events.find(e=>e.safetynet?.agent?.action==='screenshot'&&e.safetynet.agent.outcome==='completed'&&e.trace?.id);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.agent'&&s.operation==='screenshot'));assert.ok(trace.spans.some(s=>s.name==='browser.inspect.local'));assert.ok(trace.spans.some(s=>s.name==='inspector.ocr'));for(const operation of ['visual.probe','visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project'])assert.ok(trace.spans.some(s=>s.name==='browser.visual'&&s.operation===operation),operation);const byId=new Map(trace.spans.map(s=>[s.spanId,s]));for(const operation of ['visual.probe','visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project']){let span=trace.spans.find(s=>s.name==='browser.visual'&&s.operation===operation),found=false;const seen=new Set();while(span&&!seen.has(span.spanId)){if(span.name==='browser.agent'&&span.operation==='screenshot'){found=true;break;}seen.add(span.spanId);span=byId.get(span.parentSpanId);}assert.ok(found,'Visual stage parent mismatch');}let released=trace.spans.find(s=>s.name==='browser.visual'&&s.operation==='visual.release'),releaseAncestor=false;const releaseSeen=new Set();while(released&&!releaseSeen.has(released.spanId)){if(released.name==='browser.agent'&&released.operation==='agent.request'){releaseAncestor=true;break;}releaseSeen.add(released.spanId);released=byId.get(released.parentSpanId);}assert.ok(releaseAncestor,'Final visual release must belong to outer agent request');assert.ok(!JSON.stringify(trace).includes('CLASSIFIED ORCHID DOCUMENT'));assert.ok(!JSON.stringify(trace).includes('synthetic-hidden-field'));assert.ok(!JSON.stringify(trace).includes('synthetic-closed-shadow-password'));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();const avFacts=await new Promise((resolve,reject)=>{const request=require('node:https').get(origin+'/av-stats',{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);});assert.ok(avFacts.pngScans>=3);assert.equal(avFacts.pngBlocks,1);fs.writeFileSync(path.resolve('artifacts/agent-visual-export-native-proof.json'),JSON.stringify({actualElectron:true,syntheticPinnedTLS:true,defaultScopeDenied:true,independentPublicationApproved:true,individualScopeEnforced:true,publicViewportApproved:true,canonicalBoundedPNG:true,mandatoryOCRWithDocumentAnalysisDisabled:true,confidentialCanvasBlocked:true,mandatoryPNGAVBlocked:true,avFacts,localWitnesses,offscreenSensitiveControlDenied:true,closedShadowControlDenied:true,publicOpenShadowApproved:true,childFrameDenied:true,foregroundRaceDenied:true,lateClassificationPolicyDenied:true,lateForegroundDenied:true,openShadowMutationRevertDenied:true,policyRaceDenied:true,targetRevokeDenied:true,finalReleaseOuterRequestTraced:true,ordinaryAuditSecretFree:true,traceId:event.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('Agent visual export native proof passed');for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(0);
|
|
}catch(error){fs.writeFileSync(path.resolve('artifacts/agent-visual-export-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,localWitnesses},null,2));console.error('Agent visual native failed at '+stage+': '+error.name);for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(1);}});
|