Files
safetynet/scripts/windows-dlp-mvp-smoke.cjs
T

28 lines
19 KiB
JavaScript

'use strict';
const {app,BrowserWindow,session,clipboard,ClipboardItem}=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();let actualDiagnostics;const diagnosticsModule=require('../apps/browser/diagnostics.cjs'),createDiagnostics=diagnosticsModule.createDiagnostics;diagnosticsModule.createDiagnostics=(...args)=>{const instance=createDiagnostics(...args);actualDiagnostics=instance;return instance;};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'},secret='MVP Company Forecast 2030',checks=[],editorCaretFacts=[];
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 Windows DLP fixture timeout');}
app.whenReady().then(async()=>{let page,readFailureFacts,originalClipboard=[],stage='startup';try{for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);originalClipboard.push(new ClipboardItem(data));}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)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}callback(valid?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;};
const privateGET=route=>new Promise((resolve,reject)=>{const request=https.get(origin+route,{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(route==='/facts'?JSON.parse(body):true));});request.on('error',reject);});
readFailureFacts=()=>privateGET('/facts');stage='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');let loadGeneration=0;page.on('did-finish-load',()=>loadGeneration++);const navigate=async()=>{const before=loadGeneration;await ui('navigate',origin+'/workspace');await until(()=>loadGeneration>before&&!page.isLoading()&&page.getURL()===origin+'/workspace');win.focus();page.focus();await wait(200);};
const change=async modify=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;modify(p);p.version++;const generation=loadGeneration,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 s=await ui('state');return s.ready&&s.policyVersion===p.version;});await until(()=>loadGeneration>generation&&!page.isLoading());win.focus();page.focus();await wait(200);};
const key=async(code,shift=false)=>{win.focus();page.focus();page.sendInputEvent({type:'keyDown',keyCode:code,modifiers:shift?['shift']:['control']});page.sendInputEvent({type:'keyUp',keyCode:code,modifiers:shift?['shift']:['control']});await wait(400);await until(async()=>!(await ui('state')).clipboardPending);};
const setField=(id,text='')=>page.executeJavaScript(`(()=>{const e=document.getElementById(${JSON.stringify(id)});if(e.isContentEditable){e.innerHTML='<p><br></p>';if(${JSON.stringify(text)})e.firstChild.textContent=${JSON.stringify(text)};e.focus();const range=document.createRange();range.selectNodeContents(e.firstChild);range.collapse(!e.firstChild.textContent);const s=getSelection();s.removeAllRanges();s.addRange(range);}else{e.value=${JSON.stringify(text)};e.focus();e.setSelectionRange(e.value.length,e.value.length);}})()`);
const clickEditor=async()=>{const point=await page.executeJavaScript("(()=>{const r=document.getElementById('editor').getBoundingClientRect();return {x:Math.round(r.left+8),y:Math.round(r.top+10)}})()");page.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point});page.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point});await wait(80);const facts=await page.executeJavaScript("(()=>{const e=document.getElementById('editor'),s=getSelection(),r=s.rangeCount===1?s.getRangeAt(0):null,p=e.firstChild;return {focused:document.activeElement===e,collapsed:!!r?.collapsed,startOwned:!!r&&e.contains(r.startContainer),endOwned:!!r&&e.contains(r.endContainer),startInParagraph:!!r&&p.contains(r.startContainer),endInParagraph:!!r&&p.contains(r.endContainer)}})()");assert.ok(facts.focused&&facts.collapsed&&facts.startOwned&&facts.endOwned);editorCaretFacts.push({nativeClick:true,...facts});};
const value=id=>page.executeJavaScript(`(()=>{const e=document.getElementById(${JSON.stringify(id)});return e.isContentEditable?e.innerText.trimEnd():e.value;})()`);
const post=(kind,text)=>page.executeJavaScript(`fetch(${JSON.stringify('/receiver/'+kind)},{method:'POST',headers:{'content-type':'text/plain'},body:${JSON.stringify(text)}}).then(r=>r.ok).catch(()=>false)`);
stage='typed public body allowed';let before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('typed','Public typed prompt'),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);checks.push('public-body');stage='typed confidential body blocked';before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('typed',secret),false);assert.equal((await privateGET('/facts')).receiver.posts,before);checks.push('secret-body');
for(const id of ['input','textarea','editor']){stage=id+' public paste to actual receiver';await setField(id);if(id==='editor')await clickEditor();const text='Public '+id+' prompt for AI';await clipboard.writeText(text);await key('V');stage=id+' public paste commit';assert.equal(await value(id),text,'Public AI editor paste did not commit');before=(await privateGET('/facts')).receiver.posts;if(id==='input'){stage='input native form dispatch';const loading=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([loading,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before+1);await navigate();}else{assert.equal(await page.executeJavaScript(`window.mvpSubmit(${JSON.stringify(id)})`),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);}checks.push(id+'-public-paste-submit');
stage=id+' confidential paste cannot reach receiver';const baseline='Public preserved '+id;await setField(id,baseline);await clipboard.writeText(secret);await key('V');assert.equal(await value(id),baseline,'Confidential paste changed the AI editor');before=(await privateGET('/facts')).receiver.posts;assert.equal(await page.executeJavaScript(`window.mvpSubmit(${JSON.stringify(id)})`),true);const facts=await privateGET('/facts');assert.equal(facts.receiver.posts,before+1);assert.equal(facts.receiver.secretPosts,0);checks.push(id+'-secret-paste-denied');}
stage='plain part of HTML clipboard stays inert';await setField('editor');await clickEditor();await clipboard.write([new ClipboardItem({'text/plain':new Blob(['Public HTML-source prompt'],{type:'text/plain'}),'text/html':new Blob(['<p>Public HTML-source prompt</p><img id="mvp-html-must-not-render" src="/html-clipboard-network">'],{type:'text/html'})})]);await key('V');assert.equal(await value('editor'),'Public HTML-source prompt');assert.equal(await page.executeJavaScript("!!document.querySelector('#editor img,#mvp-html-must-not-render')"),false);before=(await privateGET('/facts')).receiver.posts;assert.equal(await page.executeJavaScript("window.mvpSubmit('editor')"),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);stage='confidential plain part of HTML clipboard blocked';await setField('editor','Public HTML baseline');await clipboard.write([new ClipboardItem({'text/plain':new Blob([secret],{type:'text/plain'}),'text/html':new Blob(['<p>Public decoy</p>'],{type:'text/html'})})]);await key('V');assert.equal(await value('editor'),'Public HTML baseline');checks.push('html-clipboard-inert-plain-part-dlp');
stage='native form typed public allowed';await setField('input','Public typed standard form');before=(await privateGET('/facts')).receiver.posts;let stopped=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([stopped,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before+1);await navigate();checks.push('public-standard-form');stage='native form typed confidential denied';await setField('input',secret);before=(await privateGET('/facts')).receiver.posts;stopped=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([stopped,new Promise((_,reject)=>setTimeout(()=>reject(Error('Blocked form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before);await navigate();checks.push('secret-standard-form');
const selectCopy=text=>page.executeJavaScript(`(()=>{const e=document.getElementById('copy-source');e.textContent=${JSON.stringify(text)};const range=document.createRange();range.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(range);})()`);stage='public native copy';await selectCopy('Public copy sample');await clipboard.writeText('Public clipboard sentinel');await key('C');assert.equal(await clipboard.readText(),'Public copy sample');stage='confidential copy holds OS clipboard';await clipboard.writeText('Public clipboard sentinel');await selectCopy(secret);await key('C');assert.equal(await clipboard.readText(),'Public clipboard sentinel');stage='confidential cut preserves source';await setField('input',secret);await page.executeJavaScript("document.getElementById('input').select()");await key('X');assert.equal(await clipboard.readText(),'Public clipboard sentinel');assert.equal(await value('input'),secret);checks.push('copy-cut-secret-denied');stage='public native cut';await setField('textarea','Public cut sample');await page.executeJavaScript("document.getElementById('textarea').select()");await key('X');assert.equal(await clipboard.readText(),'Public cut sample');assert.equal(await value('textarea'),'');checks.push('public-copy-cut');
stage='native NTFS public file';const doc=await page.debugger.sendCommand('DOM.getDocument',{}),node=await page.debugger.sendCommand('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});const upload=async file=>{await page.debugger.sendCommand('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[file]});return page.executeJavaScript("(()=>{const body=new FormData();body.append('file',document.getElementById('file').files[0]);return fetch('/receiver/file',{method:'POST',body}).then(r=>r.ok).catch(()=>false)})()");};assert.equal(await upload(process.env.SAFETYNET_SMOKE_PUBLIC_FILE),true);let facts=await privateGET('/facts');assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.exactPublicFile,1);stage='native NTFS confidential file denied';assert.equal(await upload(process.env.SAFETYNET_SMOKE_SECRET_FILE),false);facts=await privateGET('/facts');assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.secretPosts,0);checks.push('native-file-public-secret');
stage='actual local YARA blocks';before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('yara','MVP_YARA_ONLY_BLOCK'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);stage='actual ICAP blocks';assert.equal(await post('icap','MVP_ICAP_ONLY_BLOCK'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);stage='actual configured scanner failure fails closed';await privateGET('/scanner/fail');assert.equal(await post('scanner','Public scanner outage prompt'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);await privateGET('/scanner/normal');checks.push('real-yara-icap-scanner-failure');
stage='policy changes while real scan pending deny release';await privateGET('/scanner/gate');before=(await privateGET('/facts')).receiver.posts;const pending=post('race','Public pending scan').catch(()=>false);await until(async()=>(await privateGET('/facts')).scanner.pending);await change(p=>p.dlp.rules.push({id:'mvp-late-classification',label:'Synthetic policy race',kind:'literal',value:'Public pending scan',action:'block'}));await privateGET('/scanner/release');assert.equal(await pending,false);assert.equal((await privateGET('/facts')).receiver.posts,before);await privateGET('/scanner/normal');checks.push('policy-race-denied');
stage='central paste disable then re-enable';await change(p=>p.security.clipboard.allowPaste=false);await setField('editor');await clipboard.writeText('Public centrally disabled paste');await key('V');assert.equal(await value('editor'),'');await change(p=>p.security.clipboard.allowPaste=true);await setField('editor');await clipboard.writeText('Public centrally restored paste');await key('V');assert.equal(await value('editor'),'Public centrally restored paste');checks.push('independent-central-paste-control');
stage='real correlated diagnostics and SIEM';await wait(1800);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(!/MVP Company Forecast 2030|MVP_YARA_ONLY_BLOCK|MVP_ICAP_ONLY_BLOCK/.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 traceEvidence=[];for(const action of ['clipboard.paste','clipboard.block','upload.authorized','upload.block']){const candidates=events.filter(e=>e.event.action===action&&e.trace?.id);assert.ok(candidates.length,action+' durable audit missing');let found;for(const event of candidates.reverse()){const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();const expected=action.startsWith('clipboard')?'browser.clipboard':'browser.request';if(trace.spans?.some(s=>s.name===expected)){assert.ok(!/MVP Company Forecast 2030|MVP_YARA_ONLY_BLOCK|MVP_ICAP_ONLY_BLOCK/.test(JSON.stringify(trace)));const byId=new Map(trace.spans.map(s=>[s.spanId,s]));if(action.startsWith('upload.')){for(const operation of ['text.local','text.enterprise']){const normalized=trace.spans.find(s=>s.operation===operation);assert.ok(normalized,'Normalized URL stage absent');let current=normalized,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name==='browser.request'){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Normalized URL parent correlation absent');}}if(action==='clipboard.paste'){for(const name of ['browser.inspect.local','browser.inspect.enterprise']){const stage=trace.spans.find(s=>s.name===name);assert.ok(stage,'Clipboard inspection stage missing');let node=stage,linked=false;const seen=new Set();while(node&&!seen.has(node.spanId)){if(node.name==='browser.clipboard'){linked=true;break;}seen.add(node.spanId);node=byId.get(node.parentSpanId);}assert.ok(linked,'Clipboard inspection parent correlation missing');}}if(action==='upload.authorized'){assert.ok(trace.spans.some(s=>s.name==='browser.upload.lease'));for(const s of trace.spans.filter(s=>s.name==='browser.upload.lease'||s.name==='browser.upload.part')){let node=s,linked=false;const seen=new Set();while(node&&!seen.has(node.spanId)){if(node.name==='browser.request'){linked=true;break;}seen.add(node.spanId);node=byId.get(node.parentSpanId);}assert.ok(linked,'File stage parent correlation missing');}}found={action,traceId:event.trace.id,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))};break;}}assert.ok(found,action+' exported trace missing');traceEvidence.push(found);}facts=await privateGET('/facts');assert.equal(facts.receiver.secretPosts,0);assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.exactPublicFile,1);assert.equal(facts.scanner.blocks,1);assert.ok(facts.scanner.failures>=1);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();await actualDiagnostics.flush();const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.enabled,true);assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);
fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-native-proof.json'),JSON.stringify({actualWindows:process.platform==='win32',actualElectron:true,processId:process.pid,deviceId:(await ui('state')).deviceId,policyVersion:(await ui('state')).policyVersion,exactPinnedHTTPS:true,independentReviewedPublication:true,checks,editorCaretFacts,receiver:facts.receiver,scanner:facts.scanner,ordinaryAuditConfidentialFree:true,traceEvidence,summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},diagnostics},null,2));console.log('Windows DLP MVP native proof passed');if(originalClipboard.length)await clipboard.write(originalClipboard);else await clipboard.clear();require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
}catch(error){let failureFacts;try{failureFacts=await readFailureFacts?.();await actualDiagnostics?.flush();}catch{}fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,checks,editorCaretFacts,failureFacts,diagnostics:actualDiagnostics?.status()},null,2));console.error('Windows DLP MVP failed at '+stage+': '+error.name);try{if(originalClipboard.length)await clipboard.write(originalClipboard);else await clipboard.clear();}catch{}if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});