60 lines
7.6 KiB
JavaScript
60 lines
7.6 KiB
JavaScript
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);
|
||
|
|
});
|