Files
safetynet/scripts/controlled-login-smoke.cjs
T

27 lines
19 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();let fixtureCredentialHeader=false;const headerFacts=[];
// Inject one synthetic header at the native fixture boundary; retain the actual
// main-process listener and its original cancellation/release callback.
app.whenReady().then(()=>{const requests=session.fromPartition('persist:safetynet-managed').webRequest,original=requests.onBeforeSendHeaders.bind(requests);requests.onBeforeSendHeaders=(filter,listener)=>original(filter,(details,callback)=>{if(details.url.endsWith('/public')){const entries=Object.entries(details.requestHeaders),one=entries.find(([name])=>name.toLowerCase()==='x-credential-one'),two=entries.find(([name])=>name.toLowerCase()==='x-credential-two'),known='fixtureControlledCredentialDelta0123456789';if(one&&two){headerFacts.push({names:entries.map(([name])=>name),fragmentOneIndex:entries.indexOf(one),fragmentTwoIndex:entries.indexOf(two),fullJoinMatch:entries.map(([,value])=>value).join('').includes(known),pairJoinMatch:(one[1]+two[1])===known,reversePairJoinMatch:(two[1]+one[1])===known});fs.writeFileSync(path.resolve('artifacts/controlled-login-header-facts.json'),JSON.stringify(headerFacts,null,2));}}if(fixtureCredentialHeader&&details.resourceType==='mainFrame'&&details.method==='POST')details.requestHeaders['X-Synthetic-Credential']='sk-syntheticControlledLoginCredential0123456789';listener(details,callback);});});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<200;i++){const r=await fn();if(r)return r;await wait(100);}throw Error('Controlled login 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)})`);if(!result.ok)throw Object.assign(Error('Native UI operation failed'),{operation:method,reason:/not managed|not ready|paused/i.test(result.error||'')?'not-ready':/context.*changed/i.test(result.error||'')?'context-changed':/BLOCKED_BY_CLIENT/i.test(result.error||'')?'native-request-blocked':/ERR_ABORTED/i.test(result.error||'')?'native-navigation-aborted':'ui-operation-refused'});return result.data;};const 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+'/login');const a=await ui('agentConnection','vault-a'),b=await ui('agentConnection','vault-b'),requestFile=path.join(app.getPath('userData'),'controlled-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,master='Synthetic master password for controlled native proof',password='sk-syntheticControlledLoginCredential0123456789',username='public-native-user',alternatePassword='fixtureControlledCredentialDelta0123456789';stage='human unlock';await ui('vaultCreate',master);const saved=await ui('vaultSave',{origin,username,password,label:'Approved synthetic login'});const reset=async(mode='200')=>{await ui('navigate',origin+'/login/'+mode);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login/'+mode);await ui('vaultUnlock',master);win.focus();page.focus();await wait(100);};const stats=()=>new Promise((resolve,reject)=>{const req=require('node:https').get(origin+'/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')},res=>{let body='';res.on('data',v=>body+=v);res.on('end',()=>resolve(JSON.parse(body)));});req.on('error',reject);});const inventory=async()=>{const r=await call(a,'vault:list',{sessionId});assert.equal(r.exit,0);return r.result[0].credentialRef;};const submit=async()=>{const terminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native form navigation terminal timeout'));},22000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript("document.querySelector('form').requestSubmit()");await terminal;await wait(200);};const prepare=async()=>{const ref=await inventory(),r=await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref});assert.equal(r.exit,0,'Agent prepare failed');assert.deepEqual(r.result,{completed:true,prepared:true});return ref;};const count=async()=>{const result=await fetch(base+'/api/ad
stage='ordinary fill remains DLP blocked';await reset();assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:await inventory()})).exit,0);await submit();assert.equal((await stats()).submitted,0);
stage='agent prepare does not submit';await reset();let ref=await prepare();assert.equal((await stats()).submitted,0);assert.equal((await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref})).exit,1);assert.equal((await call(b,'vault:prepare-submit',{sessionId,credentialRef:await inventory()})).exit,1);await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/signin');assert.equal((await stats()).submitted,1);
stage='controlled302 safeGET';await reset('302');await prepare();await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/final');assert.equal((await stats()).submitted,2);assert.equal((await stats()).finalGets,1);
stage='controlled307 body blocked';await reset('307');await prepare();await submit();assert.equal((await stats()).submitted,3);assert.equal((await stats()).replayed,0);stage='slow200 completes after lease expiry';await reset('slow');await prepare();await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/signin');assert.equal((await stats()).submitted,4);
const negative=async(name,mutate)=>{stage=name;await reset();await prepare();await mutate();await submit();assert.equal((await stats()).submitted,4,name);};
await negative('confidential residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='synthetic-company-confidential-value';document.querySelector('form').append(e)"));
await negative('local YARA residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='EXTRA_YARA_RESIDUAL_SECRET';document.querySelector('form').append(e)"));await negative('ICAP residual blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='note';e.value='EXTRA_ICAP_RESIDUAL_SECRET';document.querySelector('form').append(e)"));assert.equal((await stats()).icap.blocks,1);assert.equal((await stats()).icap.credentialObserved,false);await negative('duplicate password blocked',()=>page.executeJavaScript("const e=document.createElement('input');e.name='password';e.value=document.querySelector('[name=password]').value;document.querySelector('form').append(e)"));
await negative('changed action blocked',()=>page.executeJavaScript("document.querySelector('form').action='/other'"));
await negative('vault lock invalidates',()=>ui('vaultLock'));await negative('credential header blocked',async()=>{fixtureCredentialHeader=true;});fixtureCredentialHeader=false;
stage='foreground swap invalidates';await reset();await prepare();await ui('createTab',{url:origin+'/login/200'});const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;pages.push(other);await until(()=>!other.isLoading());await ui('activateTab',sessionId);await ui('vaultUnlock',master);await submit();assert.equal((await stats()).submitted,4);
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 s=await ui('state');return s.ready&&s.policyVersion===p.version;});};
stage='central disable invalidates';await reset();await prepare();await change(p=>p.passwordManager.controlledSubmission.enabled=false);await ui('vaultUnlock',master);await win.webContents.executeJavaScript("document.getElementById('passwords').click()");await until(()=>win.webContents.executeJavaScript("!document.getElementById('vault').hidden&&document.querySelectorAll('#vault-list .login-card').length>0"));assert.equal(await win.webContents.executeJavaScript("[...document.querySelectorAll('#vault-list button')].filter(b=>b.textContent==='Prepare approved login').every(b=>b.hidden)"),true);await win.webContents.executeJavaScript("document.getElementById('vault-close').click()");await until(()=>win.webContents.executeJavaScript("document.getElementById('vault').hidden"));await page.executeJavaScript(`document.querySelector('[name=username]').value=${JSON.stringify(username)};document.querySelector('[name=password]').value=${JSON.stringify(password)}`);await submit();assert.equal((await stats()).submitted,4);await change(p=>p.passwordManager.controlledSubmission.enabled=true);
stage='target revoke invalidates';await reset();await prepare();await change(p=>p.agents.grants.find(g=>g.id==='vault-a').enabled=false);assert.equal((await call(a,'vault:status',{sessionId})).exit,1);await reset();await page.executeJavaScript(`document.querySelector('[name=username]').value=${JSON.stringify(username)};document.querySelector('[name=password]').value=${JSON.stringify(password)}`);await submit();assert.equal((await stats()).submitted,4);
stage='unregistered credential URL escape blocked';await reset();await ui('vaultSave',{id:saved.id,origin,username,password:alternatePassword,label:'Approved synthetic login'});await ui('vaultPrepareSubmit',saved.id);const getTerminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native credential GET terminal timeout'));},15000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript(`location.href='/leak?value='+encodeURIComponent(${JSON.stringify(alternatePassword)})`);await getTerminal;await wait(200);assert.equal((await stats()).credentialUrlLeaks,0);
stage='unregistered split credential headers blocked';await reset();await ui('vaultPrepareSubmit',saved.id);const split=Math.floor(alternatePassword.length/2),headerResult=await page.executeJavaScript(`fetch('/public',{method:'POST',body:'note=public',headers:{'Content-Type':'application/x-www-form-urlencoded','X-Credential-One':${JSON.stringify(alternatePassword.slice(0,split))},'X-Credential-Two':${JSON.stringify(alternatePassword.slice(split))}}}).then(()=>true,()=>false)`);if(headerResult!==false){await wait(1500);const recent=(await overview()).events,last=recent.find(e=>e.kind==='upload.authorized'&&e.metadata.traceId);if(last){const escapedTrace=await(await fetch('http://127.0.0.1:4318/api/trace/'+last.metadata.traceId)).json();for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(escapedTrace).includes(sentinel));fs.writeFileSync(path.resolve('artifacts/controlled-login-escaped-header-trace.json'),JSON.stringify({traceId:last.metadata.traceId,spans:escapedTrace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))},null,2));}}assert.equal(headerResult,false);assert.equal((await stats()).credentialHeaderLeaks,0);assert.equal((await stats()).publicPosts,0);assert.equal((await stats()).totalPosts,4);assert.equal((await stats()).submitted,4);await ui('vaultSave',{id:saved.id,origin,username,password,label:'Approved synthetic login'});
stage='human rendered panel handoff';await reset();await win.webContents.executeJavaScript("document.getElementById('passwords').click()");await until(()=>win.webContents.executeJavaScript("!document.getElementById('vault').hidden"));await until(()=>win.webContents.executeJavaScript("[...document.querySelectorAll('#vault-list button')].some(b=>b.textContent==='Prepare approved login'&&!b.hidden)"));await win.webContents.executeJavaScript("document.querySelectorAll('#vault-list .login-card p:nth-of-type(2)').forEach(e=>e.style.color='transparent')");fs.writeFileSync(path.resolve('artifacts/controlled-login-browser-vault.png'),(await win.webContents.capturePage()).toPNG());await win.webContents.executeJavaScript("document.getElementById('vault-master').value='synthetic-unused-master-input';document.getElementById('vault-password').value='synthetic-unused-save-input'");await ui('vaultPrepareSubmit',saved.id);assert.equal(await win.webContents.executeJavaScript("document.getElementById('vault').hidden&&document.getElementById('vault-master').value===''&&document.getElementById('vault-password').value===''"),true);assert.equal((await ui('vaultStatus')).locked,false);assert.equal((await stats()).submitted,4);await ui('vaultLock');
stage='audit and actual traces';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){for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(row).includes(sentinel));events.push({kind:row.event.action,metadata:{traceId:row.trace?.id,ruleId:row.rule?.id}});}const next=Number(response.headers.get('x-safetynet-next-cursor'));if(rows.length<1000)break;assert.ok(next>after);after=next;}assert.ok(events.some(e=>e.kind==='dlp.block'&&e.metadata.ruleId==='controlled-residual-yara'));for(const e of events)for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(e).includes(sentinel));await wait(1500);const completed=events.find(e=>e.kind==='vault.submission-completed'&&e.metadata.traceId);assert.ok(completed);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+completed.metadata.traceId)).json(),required=['credential.claim','credential.parse','credential.match','credential.residual','credential.headers','credential.header-values','credential.authorize','credential.complete'];for(const operation of required)assert.ok(trace.spans.some(s=>s.name==='browser.credential'&&s.operation===operation),operation);const byId=new Map(trace.spans.map(s=>[s.spanId,s]));for(const operation of required){let span=trace.spans.find(s=>s.name==='browser.credential'&&s.operation===operation),found=false;const seen=new Set();while(span&&!seen.has(span.spanId)){if(span.name==='browser.request'){found=true;break;}seen.add(span.spanId);span=byId.get(span.parentSpanId);}assert.ok(found,'Missing request ancestor: '+operation);}assert.ok(trace.spans.some(s=>s.name==='browser.inspect.local'));assert.ok(trace.spans.some(s=>s.name==='browser.inspect.enterprise'));for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])assert.ok(trace.spans.some(s=>s.name==='browser.security'&&s.operation===operation));for(const sentinel of [master,password,username,alternatePassword])assert.ok(!JSON.stringify(trace).includes(sentinel));const proofs=[{traceId:completed.metadata.traceId,commonLifecycleTrace:true,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))}];const terminal=events.filter(e=>e.kind==='vault.submission-completed');assert.ok(terminal.length>=3);const receiver=await stats(),receiverFacts={totalPosts:receiver.totalPosts,credentialPosts:receiver.submitted,publicPosts:receiver.publicPosts,replayedPosts:receiver.replayed,credentialUrlLeaks:receiver.credentialUrlLeaks,credentialHeaderLeaks:receiver.credentialHeaderLeaks};assert.equal(receiverFacts.totalPosts,4);assert.equal(receiverFacts.publicPosts,0);const scannerFacts=receiver.icap;assert.equal(scannerFacts.credentialObserved,false);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/controlled-login-native-proof.json'),JSON.stringify({actualElectron:true,exactPinnedSyntheticHTTPS:true,ordinaryFillDLPBlocked:true,agentPrepareNeverSubmits:true,approvedPOST200:true,approvedPOST302SafeGET:true,redirect307BodyBlocked:true,residualBlocked:true,duplicatePasswordBlocked:true,changedActionBlocked:true,nativeCredentialHeaderBlocked:true,unregisteredCredentialUrlBlocked:true,unregisteredSplitHeadersBlocked:true,centralPrepareControlHidden:true,vaultLockInvalidates:true,foregroundSwapInvalidates:true,centralDisableInvalidates:true,targetRevokeInvalidates:true,referenceReplayDenied:true,humanUIFieldsCleared:true,handoffDoesNotLock:true,ordinaryAuditSecretFree:true,localYaraResidualBlocked:true,icapResidualBlocked:true,icapCredentialFree:true,slowTerminalAfterExpiry:true,receiverFacts,s
}catch(error){fs.writeFileSync(path.resolve('artifacts/controlled-login-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,operation:error.operation,reason:error.reason,headerFacts},null,2));console.error('Controlled login 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);}});