import test from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import {createRequire} from 'node:module'; const {createVisualPhishing,describeScript}=createRequire(import.meta.url)('../apps/browser/visual-phishing.cjs'); function fixture(overrides={}){ let url='https://evil.test/login';const events=[],blocks=[]; const reference={hash:'0123456789abcdef',algorithm:'phash-dct32-v1',scope:'viewport',viewport:{width:1320,height:900}}; const policy={version:1,expiresAt:new Date(Date.now()+60000).toISOString(),security:{visualPhishing:{enabled:true,failClosed:true,brands:[{id:'company-login',label:'Company',allowedOrigins:['https://company.test'],requiredText:['Company'],maxDistance:6,references:[reference]}]}}}; const context={ready:true,policy,webContents:{getURL:()=>url},navigationId:1}; const settings={getContext:()=>({...context,url}),describe:async()=>({hasCredentialInputs:true,visibleText:'Company login'}),capture:async()=>({width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}}),fingerprint:async image=>image.fingerprint,compare:async()=>0,audit:async event=>events.push(event),block:async result=>blocks.push(result),...overrides}; return {checker:createVisualPhishing(settings),context,policy,reference,events,blocks,setURL:value=>url=value}; } test('strong brand match on credential page blocks and emits metadata-only audit',async()=>{ const f=fixture();const r=await f.checker.check();assert.equal(r.decision,'block');assert.equal(r.ruleId,'company-login');assert.equal(f.blocks.length,1);assert.deepEqual(f.events,[{kind:'phishing.block',ruleId:'company-login',host:'evil.test'}]);assert.equal(JSON.stringify(f.events).includes('012345'),false); }); test('exact HTTPS own-brand origin exempts only that brand',async()=>{ const f=fixture();f.setURL('https://company.test/login');assert.equal((await f.checker.check()).decision,'allow'); f.policy.security.visualPhishing.brands.push({...f.policy.security.visualPhishing.brands[0],id:'other-brand',allowedOrigins:['https://other.test']});assert.equal((await f.checker.check()).ruleId,'other-brand'); }); test('HTTP, suffix and lookalike origins cannot reuse trusted exemption',async()=>{ for(const url of ['http://company.test/login','https://company.test.evil.test/login','https://c0mpany.test/login']){const f=fixture();f.setURL(url);assert.equal((await f.checker.check()).decision,'block');} }); test('unrelated text, absent credentials and dissimilar image are allowed',async()=>{ for(const settings of [{describe:async()=>({hasCredentialInputs:true,visibleText:'Different unrelated sign in'})},{describe:async()=>({hasCredentialInputs:false,visibleText:'Company brochure'})},{compare:async()=>40}]){const f=fixture(settings);assert.equal((await f.checker.check()).decision,'allow');assert.equal(f.blocks.length,0);} }); test('changed URL, policy identity or document epoch discards asynchronous match',async()=>{ for(const change of ['url','policy','document','reset']){const f=fixture({capture:async()=>{if(change==='url')f.setURL('https://another.test/');if(change==='policy')f.context.policy={...f.policy};if(change==='document')f.context.navigationId++;if(change==='reset')f.checker.reset();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});const r=await f.checker.check();assert.equal(r.decision,'unknown');assert.equal(r.reason,'stale-context');assert.equal(f.blocks.length,0);} }); test('real capture failure on credential page fails closed; unsupported dimensions remain unknown',async()=>{ const failed=fixture({capture:async()=>{throw Error('Screenshot unavailable');}});assert.equal((await failed.checker.check()).decision,'block'); const mismatch=fixture({capture:async()=>({width:800,height:600,fingerprint:{hash:'0123456789abcdef',usable:true}})});assert.equal((await mismatch.checker.check()).decision,'unknown');assert.equal(mismatch.blocks.length,0); const unusable=fixture({fingerprint:async()=>({usable:false,hash:'0123456789abcdef'})});assert.equal((await unusable.checker.check()).decision,'unknown'); }); test('description failure fails closed and expired policy never produces approval',async()=>{ const failed=fixture({describe:async()=>{throw Error('Unavailable');}});const verdict=await failed.checker.check();assert.equal(verdict.decision,'block');assert.equal(verdict.reason,'visual-description-unavailable'); const expired=fixture();expired.policy.expiresAt=new Date(Date.now()-1).toISOString();assert.equal((await expired.checker.check()).reason,'policy-expired'); const stale=fixture({capture:async()=>{stale.policy.expiresAt=new Date(Date.now()-1).toISOString();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});assert.equal((await stale.checker.check()).reason,'stale-context');assert.equal(stale.blocks.length,0); }); test('login-form references require aspect match and rectangle',async()=>{ const f=fixture({describe:async()=>({hasCredentialInputs:true,visibleText:'Company',formRect:{x:0,y:0,width:600,height:300}}),capture:async()=>({width:600,height:300,fingerprint:{hash:'0123456789abcdef',usable:true}})});f.reference.scope='login-form';f.reference.viewport={width:300,height:150};assert.equal((await f.checker.check()).decision,'block'); }); test('description script sees email-only login without collecting values',()=>{ const body={parentElement:null};const input={type:'email',name:'email',id:'email',value:'must-never-read',disabled:false,parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null,getBoundingClientRect:()=>({x:10,y:10,right:310,bottom:50})};const textElement={parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null};let visited=false; Object.defineProperty(input,'value',{get(){throw Error('Field value access forbidden');}}); const document={body,querySelectorAll:()=>[input],createTreeWalker:()=>({nextNode:()=>visited?null:(visited=true,{parentElement:textElement,textContent:'Company'})})}; const sandbox={location:{href:'https://evil.test/login'},document,innerWidth:1320,innerHeight:900,NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}; const result=vm.runInNewContext(describeScript('https://evil.test/login'),sandbox);assert.equal(result.hasCredentialInputs,true);assert.equal(result.visibleText,'Company');assert.equal(JSON.stringify(result).includes('must-never'),false); sandbox.location.href='https://replacement.test';assert.throws(()=>vm.runInNewContext(describeScript('https://evil.test/login'),sandbox),/Document changed/); }); test('concurrent checks serialize capture and forced checks take fresh snapshots',async()=>{ let active=0,max=0;const f=fixture({capture:async()=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,10));active--;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}}); await Promise.all([f.checker.check(),f.checker.check({force:true}),f.checker.check({force:true})]);assert.equal(max,1); }); test('multiple references reuse one capture and fingerprint while validating each reference viewport',async()=>{ let captures=0,fingerprints=0,comparisons=0; const f=fixture({capture:async()=>{captures++;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};},fingerprint:async image=>{fingerprints++;return image.fingerprint;},compare:async()=>{comparisons++;return 40;}}); f.policy.security.visualPhishing.brands[0].references.push({...f.reference,hash:'aaaaaaaaaaaaaaaa'},{...f.reference,hash:'bbbbbbbbbbbbbbbb',viewport:{width:800,height:600}}); assert.equal((await f.checker.check()).decision,'unknown');assert.equal(captures,1);assert.equal(fingerprints,1);assert.equal(comparisons,2); });