'use strict'; // `unknown` is never an approval: callers must hold credential actions until a fresh verdict. function describeDocument(expectedURL){ if(location.href!==expectedURL)throw Error('Document changed'); const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript'))return false;let p=e,count=0;while(p&&count++<32){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return e.getClientRects().length>0;}; const candidates=[]; for(const e of document.querySelectorAll('input')){ if(candidates.length>=32)break; if(!visible(e)||e.disabled||e.type==='hidden')continue; if(/^(password|email)$/i.test(e.type)||/username|email|login|current-password|new-password/i.test([e.name,e.id,e.getAttribute('autocomplete')].join(' ')))candidates.push(e); } let formRect; if(candidates.length){const container=candidates[0].closest('form')||candidates[0].closest('main,[role="main"]')||candidates[0];const r=container.getBoundingClientRect();const x=Math.max(0,r.x),y=Math.max(0,r.y),width=Math.min(innerWidth,r.right)-x,height=Math.min(innerHeight,r.bottom)-y;if(width>=16&&height>=16)formRect={x,y,width,height};} const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node,text='',visited=0; while((node=walker.nextNode())&&visited++<20000&&text.length<8000){const e=node.parentElement;if(!e||e.closest('input,textarea,select,[contenteditable],script,style,template,noscript')||!visible(e))continue;const piece=(node.textContent||'').replace(/\s+/g,' ').trim();if(piece)text+=(text?'\n':'')+piece.slice(0,8000-text.length);} return {hasCredentialInputs:candidates.length>0,visibleText:text.slice(0,8000),...(formRect?{formRect}:{})}; } const describeScript=expectedURL=>`(${describeDocument.toString()})(${JSON.stringify(expectedURL)})`; const normalize=text=>String(text).normalize('NFKC').toLowerCase().replace(/ß/g,'ss').replace(/ς/g,'σ'); function createVisualPhishing({getContext,capture,describe,fingerprint,compare,audit,block}={}){ if([getContext,capture,describe,fingerprint,compare,audit,block].some(fn=>typeof fn!=='function'))throw TypeError('Trusted visual phishing callbacks required'); let pending=null,generation=0; const snapshot=()=>{const c=getContext();return {...c,url:c?.url||c?.webContents?.getURL(),generation};}; const current=initial=>{const c=snapshot();return c.ready&&Number.isFinite(Date.parse(c.policy?.expiresAt))&&Date.parse(c.policy.expiresAt)>Date.now()&&c.policy===initial.policy&&c.webContents===initial.webContents&&c.url===initial.url&&c.navigationId===initial.navigationId&&c.generation===initial.generation;}; const result=(initial,decision,reason,ruleId)=>({decision,reason,...(ruleId?{ruleId}:{}),checkedURL:initial.url,policyVersion:initial.policy?.version}); async function run(){ const initial=snapshot(),settings=initial.policy?.security?.visualPhishing; if(!initial.ready)return result(initial,'unknown','browser-not-ready'); if(!Number.isFinite(Date.parse(initial.policy?.expiresAt))||Date.parse(initial.policy.expiresAt)<=Date.now())return result(initial,'unknown','policy-expired'); if(!settings?.enabled)return result(initial,'allow','visual-policy-disabled'); let pageURL;try{pageURL=new URL(initial.url);if(!['https:','http:'].includes(pageURL.protocol))throw Error();}catch{return result(initial,'unknown','unsupported-url');} const blocked=async(reason,ruleId)=>{ if(!current(initial))return result(initial,'unknown','stale-context'); await audit({kind:'phishing.block',ruleId:ruleId||'visual-inspection-failed',host:pageURL.hostname}); if(!current(initial))return result(initial,'unknown','stale-context'); const decision=result(initial,'block',reason,ruleId||'visual-inspection-failed');await block(decision,initial); return decision; }; let description; try{description=await describe(initial.url,initial);if(!current(initial))return result(initial,'unknown','stale-context');if(typeof description?.hasCredentialInputs!=='boolean'||typeof description.visibleText!=='string'||description.visibleText.length>8000)throw Error();} catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('visual-description-unavailable'):result(initial,'unknown','description-unavailable');} if(!description.hasCredentialInputs)return result(initial,'allow','no-credential-inputs'); const text=normalize(description.visibleText),captures=new Map(),features=new Map();let comparable=0,unknown=false; try{ for(const brand of settings.brands){ // Exemption applies only to this brand; other protected brands remain checked. if(pageURL.protocol==='https:'&&brand.allowedOrigins.includes(pageURL.origin))continue; if(!brand.requiredText.every(phrase=>text.includes(normalize(phrase))))continue; for(const reference of brand.references){ if(reference.scope==='login-form'&&!description.formRect){unknown=true;continue;} const key=reference.scope;let image; if(captures.has(key))image=captures.get(key);else{image=await capture(reference.scope,description,reference);if(!current(initial))return result(initial,'unknown','stale-context');captures.set(key,image);} if(reference.viewport){ if(!Number.isFinite(image.width)||!Number.isFinite(image.height)||image.width<=0||image.height<=0)throw Error('Capture dimensions unavailable'); const expected=reference.viewport; if(reference.scope==='viewport'?(image.width!==expected.width||image.height!==expected.height):Math.abs((image.width/image.height)/(expected.width/expected.height)-1)>.1){unknown=true;continue;} } const featureKey=`${reference.scope}:${reference.algorithm}`;let feature; if(features.has(featureKey))feature=features.get(featureKey); else{feature=await fingerprint(image,reference.algorithm);if(!current(initial))return result(initial,'unknown','stale-context');features.set(featureKey,feature);} const hash=typeof feature==='string'?feature:feature?.hash; if(feature?.usable===false||!/^[a-f0-9]{16}$/.test(hash||'')){unknown=true;continue;} const distance=await compare(hash,reference.hash);if(!current(initial))return result(initial,'unknown','stale-context'); if(!Number.isInteger(distance)||distance<0||distance>64)throw Error('Comparison unavailable'); comparable++; if(distance<=brand.maxDistance)return blocked('credential-brand-impersonation',brand.id); } } }catch{if(!current(initial))return result(initial,'unknown','stale-context');return settings.failClosed?blocked('credential-visual-inspection-failed'):result(initial,'unknown','visual-inspection-failed');} if(unknown)return result(initial,'unknown','reference-not-comparable'); return result(initial,'allow',comparable?'no-protected-brand-match':'no-applicable-brand-reference'); } function check({force=false}={}){ if(pending&&!force)return pending; const prior=pending;const operation=(prior?prior.catch(()=>{}):Promise.resolve()).then(run); pending=operation;operation.finally(()=>{if(pending===operation)pending=null;}).catch(()=>{});return operation; } return {check,reset:()=>{generation++;}}; } module.exports={createVisualPhishing,describeScript};