Files
safetynet/apps/browser/visual-phishing.cjs
T

80 lines
7.3 KiB
JavaScript
Raw Normal View History

2026-10-13 10:33:31 +02:00
'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};