first commit
No files matched your search
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
function domAction(mode, selector, text, expectedUrl) {
|
||||
if (location.href !== expectedUrl) throw Error('Document changed before action');
|
||||
const visible = e => {
|
||||
if (!e || e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript')) return false;
|
||||
const style = getComputedStyle(e);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && e.getClientRects().length > 0;
|
||||
};
|
||||
const sensitive = e => {
|
||||
const forbidden = /password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
|
||||
for (let node = e; node && node !== document.body; node = node.parentElement) {
|
||||
if (['name','id','autocomplete','aria-label','data-sensitive','action'].some(a => forbidden.test(node.getAttribute(a) || ''))) return true;
|
||||
}
|
||||
return /^(password|file|hidden)$/i.test(e.type || '');
|
||||
};
|
||||
if (mode === 'read') {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
const parts = []; let length = 0, visited = 0, node;
|
||||
while ((node = walker.nextNode()) && visited++ < 20000 && length < 10000) {
|
||||
const element = node.parentElement;
|
||||
if (!element || element.closest('input,textarea,select,option,[contenteditable],script,style,template,noscript') || !visible(element) || sensitive(element)) continue;
|
||||
const value = (node.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (value) { const piece = value.slice(0, 10000 - length); parts.push(piece); length += piece.length + 1; }
|
||||
}
|
||||
return { text: parts.join('\n').slice(0,10000) };
|
||||
}
|
||||
const e = document.querySelector(selector);
|
||||
if (!visible(e) || sensitive(e) || e.disabled || e.getAttribute('aria-disabled') === 'true') throw Error('Target is unavailable or sensitive');
|
||||
if (mode === 'click') {
|
||||
if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked');
|
||||
e.click(); return {completed:true};
|
||||
}
|
||||
if (mode === 'type') {
|
||||
if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text');
|
||||
const prototype = e.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(prototype,'value').set.call(e,text);
|
||||
e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return {completed:true};
|
||||
}
|
||||
throw Error('Unsupported DOM action');
|
||||
}
|
||||
const secret = text => /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(text);
|
||||
const script = (mode,args,url) => `(${domAction.toString()})(${JSON.stringify(mode)},${JSON.stringify(args.selector || '')},${JSON.stringify(args.text || '')},${JSON.stringify(url)})`;
|
||||
const hostMatches = (hostname, pattern) => {
|
||||
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
|
||||
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
|
||||
};
|
||||
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,inspect,audit,appControls={}} = {}) {
|
||||
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
|
||||
const snapshot = (args,signal,action) => {
|
||||
const c = getContext(args.sessionId);
|
||||
if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session');
|
||||
if (['click','type'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
const url = c.webContents.getURL();
|
||||
if (typeof c.destinationAllowed!=='function' || (action!=='navigate' && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
|
||||
const agents=c.policy.agents;
|
||||
if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied');
|
||||
if (!Array.isArray(agents.allowedHosts) || (action!=='navigate' && agents.allowedHosts.length && !agents.allowedHosts.some(p=>hostMatches(new URL(url).hostname,p)))) throw Error('Agent destination denied');
|
||||
return {...c,url,action,version:c.policyVersion ?? c.policy.version};
|
||||
};
|
||||
const check = (initial,args,signal,expectedUrl=initial.url) => {
|
||||
const current = snapshot({...args,sessionId:initial.sessionId},signal,initial.action);
|
||||
if (!contextCurrent(initial.token) || current.version !== initial.version || current.webContents !== initial.webContents || current.url !== expectedUrl) throw Error('Managed session changed during action');
|
||||
return current;
|
||||
};
|
||||
const classify = async (text,initial,args,signal,allowSensitive=false) => {
|
||||
if (typeof text!=='string' || text.length>10000 || secret(text)) throw Error('Sensitive content cannot be returned');
|
||||
const verdict = await inspect(text,initial);check(initial,args,signal);
|
||||
if (!verdict || !['allow','block'].includes(verdict.decision)) throw Error('Inspection unavailable');
|
||||
const reserved=/^(?:private-key|aws-access-key|api-secret|uninspectable|size-limit|inspection-incomplete|yara-.*)$/;
|
||||
const knownCorporateRule=verdict.ruleId==='content-blocklist' || initial.policy.dlp?.rules?.some(rule=>rule.id===verdict.ruleId && ['literal','sha256','fragment-sha256'].includes(rule.kind));
|
||||
const override=allowSensitive && knownCorporateRule && !reserved.test(verdict.ruleId || '') && !verdict.malware && verdict.engine!=='yara' && verdict.coverage?.complete!==false;
|
||||
if (verdict.decision==='block' && !override) { await audit({action:'agent.read',outcome:'blocked'});throw Error('Company policy blocks content access'); }
|
||||
return text;
|
||||
};
|
||||
const dom = mode => async (args,{signal}={}) => {
|
||||
const initial = snapshot(args,signal,mode);
|
||||
if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector');
|
||||
if (mode==='type' && (typeof args.text!=='string' || args.text.length>16384)) throw Error('Invalid text');
|
||||
const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false);
|
||||
check(initial,args,signal);
|
||||
if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)};
|
||||
return {completed:result?.completed===true};
|
||||
};
|
||||
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
|
||||
const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;};
|
||||
const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{
|
||||
const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version;
|
||||
const current=()=>{const c=appSnapshot(action);if(signal?.aborted||c.policy!==initial.policy||(c.policyVersion??c.policy.version)!==version)throw Error('Managed session changed during action');};
|
||||
if(signal?.aborted||typeof appControls[action]!=='function')throw Error('Managed session unavailable');
|
||||
if(action==='tabs:create'&&args.url){const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Agent destination denied');}
|
||||
current();const result=await appControls[action](args,{signal,policyVersion:version});current();
|
||||
if(action==='bookmarks:list'){
|
||||
const entries=[...(result?.managed||[]),...(result?.personal?.items||[])];
|
||||
for(const item of entries){const text=[item.title,item.url,item.folder].filter(v=>typeof v==='string').join('\n');if(text.length>10000||secret(text))throw Error('Sensitive content cannot be returned');const verdict=await inspect(text,{...initial,action});current();if(verdict?.decision!=='allow')throw Error('Company policy blocks content access');}
|
||||
}
|
||||
return result;
|
||||
}]));
|
||||
return {
|
||||
...appAdapters,
|
||||
sessions:async(args={}, {signal}={})=>{
|
||||
const c=getContext();
|
||||
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true}));
|
||||
},
|
||||
navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};},
|
||||
read:dom('read'),click:dom('click'),type:dom('type'),
|
||||
ai:async(args,{signal}={})=>{const initial=snapshot(args,signal,'ai');if(typeof args.prompt!=='string'||args.prompt.length>16384)throw Error('Invalid prompt');const result=await ai(args.prompt,{signal});check(initial,args,signal);const text=typeof result==='string'?result:result?.text ?? result?.answer;return {text:await classify(text,initial,args,signal,false)};}
|
||||
};
|
||||
}
|
||||
module.exports={createAgentAdapters};
|
||||
@@ -0,0 +1,23 @@
|
||||
const {app}=require('electron');
|
||||
const fs=require('node:fs'),path=require('node:path');
|
||||
function configureClient(connectionFile){
|
||||
if(!connectionFile||!path.isAbsolute(connectionFile))throw Error('Absolute encrypted pairing file required');
|
||||
app.setPath('userData',path.join(app.getPath('appData'),'safetynet-agent-helper'));
|
||||
}
|
||||
async function runClient(connectionFile,requestFile){
|
||||
let stage='credential-store';try{
|
||||
if(!requestFile)throw Error('Request file required');
|
||||
stage='pairing';const descriptor=JSON.parse(fs.readFileSync(connectionFile,'utf8'));
|
||||
if(descriptor.format!=='windows-dpapi-v1')throw Error('Pairing format rejected');
|
||||
const connection=JSON.parse(await require('./dpapi.cjs').capabilityCrypto('unprotect',descriptor.protectedConnection));
|
||||
const origin=new URL(connection.address);
|
||||
if(origin.protocol!=='http:'||origin.hostname!=='127.0.0.1'||origin.username||origin.password||origin.pathname!=='/'||origin.search||origin.hash)throw Error('Agent endpoint rejected');
|
||||
stage='request-file';const info=fs.statSync(requestFile);if(!info.isFile()||info.size>32768)throw Error('Request too large');
|
||||
const body=fs.readFileSync(requestFile);if(body.length>32768)throw Error('Request too large');JSON.parse(body.toString());
|
||||
stage='transport';const response=await require('./loopback-http.cjs').postLoopback({port:Number(origin.port),route:'/v1/actions',token:connection.token,body:JSON.parse(body.toString()),signal:AbortSignal.timeout(15000)});
|
||||
stage='policy-response';
|
||||
const result=response.body;if(response.status!==200)throw Error('Agent policy denied action');
|
||||
process.stdout.write(JSON.stringify(result)+'\n');app.exit(0);
|
||||
}catch(error){error.clientStage=stage;throw error;}
|
||||
}
|
||||
module.exports={configureClient,runClient};
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', read: 'browser:read', click: 'browser:interact', type: 'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
|
||||
return value;
|
||||
};
|
||||
function validate(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => !['action', 'args'].includes(k))) throw fail(400, 'Invalid action envelope');
|
||||
const action = body.action;
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { sessions: [], navigate: ['sessionId', 'url'], read: ['sessionId'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
|
||||
if (action === 'navigate') {
|
||||
let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw fail(400, 'Unsupported URL');
|
||||
clean.url = url.href;
|
||||
}
|
||||
if (['click', 'type'].includes(action)) {
|
||||
clean.selector = bounded(args.selector, 512, 'selector');
|
||||
// A small CSS subset prevents pseudo-elements, escapes and control characters.
|
||||
if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
|
||||
}
|
||||
if (action === 'type') clean.text = bounded(args.text, 16384, 'text');
|
||||
if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt');
|
||||
if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='tabs:create'&&args.url!==undefined){let url;try{url=new URL(bounded(args.url,4096,'url'));}catch{throw fail(400,'Invalid URL');}if(!['http:','https:'].includes(url.protocol)||url.username||url.password)throw fail(400,'Unsupported URL');clean.url=url.href;}
|
||||
if(['tabs:create','tabs:group'].includes(action)&&args.group!==undefined){if(typeof args.group!=='string'||args.group.length>40||/[\u0000-\u001f\u007f]/.test(args.group))throw fail(400,'Invalid group');clean.group=args.group;}
|
||||
if(action==='tabs:group'&&args.group===undefined)throw fail(400,'Invalid group');
|
||||
if(action==='settings:update'){try{clean.appearance=require('./managed-settings.cjs').validateAppearance(args.appearance);}catch{throw fail(400,'Invalid appearance settings');}}
|
||||
if(action==='bookmarks:mutate'){
|
||||
if(!['add','update','delete'].includes(args.action)||!Number.isSafeInteger(args.expectedRevision)||args.expectedRevision<0||!Number.isSafeInteger(args.policyVersion)||args.policyVersion<1)throw fail(400,'Invalid bookmark operation');
|
||||
clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion;
|
||||
if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};}
|
||||
}
|
||||
return { action, args: clean, scope: SCOPES[action], sessionId: clean.sessionId };
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0};
|
||||
if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};}
|
||||
if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};}
|
||||
if(action.startsWith('tabs:')||action==='bookmarks:mutate')return {completed:result?.completed===true,...(typeof result?.sessionId==='string'?{sessionId:bounded(result.sessionId,128,'session id')}:{})};
|
||||
if (action === 'sessions') {
|
||||
if (!Array.isArray(result) || result.length > 100) throw fail(502, 'Invalid session response');
|
||||
return result.map(s => ({ id: bounded(s.id, 128, 'session id'), ...(typeof s.title === 'string' ? { title: s.title.slice(0, 256) } : {}), ...(typeof s.foreground === 'boolean' ? {foreground:s.foreground} : {}) }));
|
||||
}
|
||||
if (action === 'read' || action === 'ai') {
|
||||
if (typeof result?.text !== 'string' || result.text.length > 65536) throw fail(502, 'Invalid text response');
|
||||
return { text: result.text };
|
||||
}
|
||||
return { completed: result?.completed !== false };
|
||||
}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, timeoutMs = 10000, maxRequestsPerMinute = 60 } = {}) {
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits');
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const expected = Buffer.from(`Bearer ${token}`);
|
||||
let enabled = true, windowStart = Date.now(), requests = 0;
|
||||
const running = new Set();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
|
||||
const controller = new AbortController();
|
||||
let action, outcome = 'denied', timer;
|
||||
try {
|
||||
const supplied = Buffer.from(req.headers.authorization || '');
|
||||
if (!enabled || supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) throw fail(401, 'Unauthorized');
|
||||
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
|
||||
if (req.method !== 'POST' || req.url !== '/v1/actions') throw fail(404, 'Not found');
|
||||
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'JSON required');
|
||||
if (Date.now() - windowStart >= 60000) { windowStart = Date.now(); requests = 0; }
|
||||
if (++requests > maxRequestsPerMinute) throw fail(429, 'Rate limit exceeded');
|
||||
let size = 0, chunks = [];
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 32768) throw fail(413, 'Request too large'); chunks.push(chunk); }
|
||||
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
|
||||
const request = validate(body); action = request.action;
|
||||
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
||||
running.add(controller);
|
||||
const operation = (async () => {
|
||||
const grant = await authorize({ ...request, signal: controller.signal });
|
||||
if (!enabled || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
|
||||
await audit({ action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' });
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
return project(action, await actions[action](request.args, { signal: controller.signal }));
|
||||
})();
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
outcome = 'completed';
|
||||
await audit({ action, outcome });
|
||||
reply(200, { result });
|
||||
} catch (error) {
|
||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||
if (typeof onError === 'function') {
|
||||
const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out'];
|
||||
const reason=reasons.find(value=>String(error.message).includes(value)) || 'unclassified-action-failure';
|
||||
try { await onError({action:action || 'agent-request',status:error.status || 500,reason}); } catch { /* Diagnostics cannot change enforcement. */ }
|
||||
}
|
||||
try { await audit({ action: action || 'agent-request', outcome, status: error.status || 500 }); } catch { /* Deny even if audit unavailable. */ }
|
||||
reply(error.status || 500, { error: error.status ? error.message : 'Agent action failed' });
|
||||
} finally { clearTimeout(timer); running.delete(controller); }
|
||||
});
|
||||
server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||||
const address = `http://127.0.0.1:${server.address().port}`;
|
||||
const revoke = () => { enabled = false; for (const c of running) c.abort(); };
|
||||
const close = async () => { revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
|
||||
try {
|
||||
const credential = { address, token };
|
||||
if (persistCredential) await persistCredential(credential);
|
||||
if (credentialFile) {
|
||||
if (!path.isAbsolute(credentialFile)) throw new TypeError('Credential path must be absolute');
|
||||
await fs.writeFile(credentialFile, JSON.stringify(credential), { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
} catch (error) { await close(); throw error; }
|
||||
return { address, token, close, revoke };
|
||||
}
|
||||
module.exports = { startAgentControl, SCOPES };
|
||||
@@ -0,0 +1,25 @@
|
||||
const {requireSection}=require('./managed-settings.cjs');
|
||||
function libraryVisible(settings){return !!settings?.available&&!settings.hiddenSections.includes('library');}
|
||||
function bookmarkState({policy,ready,settings}){
|
||||
const rules=policy?.browser?.bookmarks;
|
||||
if(!ready||!libraryVisible(settings)||!rules)return {managed:[],personal:{revision:0,items:[]},allowUserAdd:false,allowUserEdit:false,allowUserDelete:false};
|
||||
const result=structuredClone(rules);if(!settings.visible||settings.lockedSections.includes('library'))result.allowUserAdd=result.allowUserEdit=result.allowUserDelete=false;return result;
|
||||
}
|
||||
function validateMutation(input,{policy,ready,settings,destinationAllowed,now=Date.now()}){
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=now)throw Error('Managed browser access is paused');
|
||||
requireSection(settings,'library');
|
||||
const rules=policy.browser.bookmarks;
|
||||
if(!input||!['add','update','delete'].includes(input.action)||Object.keys(input).some(key=>!['action','bookmark','id','expectedRevision','policyVersion'].includes(key)))throw Error('Invalid bookmark operation');
|
||||
if(input.expectedRevision!==rules.personal.revision||input.policyVersion!==policy.version)throw Error('Bookmarks changed. Refresh before editing.');
|
||||
if(!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[input.action]])throw Error('Bookmark changes are controlled by your administrator');
|
||||
const id=input.action==='delete'?input.id:input.bookmark?.id;
|
||||
if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,80}$/.test(id)||rules.managed.some(item=>item.id===id))throw Error('Company bookmarks cannot be changed');
|
||||
if(input.action==='add'&&rules.personal.items.some(item=>item.id===id)||input.action!=='add'&&!rules.personal.items.some(item=>item.id===id))throw Error('Bookmark identity is unavailable');
|
||||
if(input.action==='delete')return {action:input.action,id,expectedRevision:input.expectedRevision,policyVersion:input.policyVersion};
|
||||
const item=input.bookmark;
|
||||
if(Object.keys(item).some(key=>!['id','title','url','folder'].includes(key))||typeof item.title!=='string'||!item.title.trim()||item.title.length>120||/[\u0000-\u001f\u007f]/.test(item.title)||typeof item.folder!=='string'||item.folder.length>80||/[\u0000-\u001f\u007f]/.test(item.folder)||typeof item.url!=='string'||item.url.length>2048)throw Error('Invalid bookmark details');
|
||||
let url;try{url=new URL(item.url);}catch{throw Error('Invalid bookmark URL');}
|
||||
if(!['https:','http:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||!destinationAllowed(url.href,policy))throw Error('Bookmark destination is not approved');
|
||||
return {action:input.action,bookmark:{id,title:item.title.trim(),url:url.href,folder:item.folder.trim()},expectedRevision:input.expectedRevision,policyVersion:input.policyVersion};
|
||||
}
|
||||
module.exports={bookmarkState,validateMutation,libraryVisible};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Session frame preload: no page bridge, Node engine, filesystem API or clipboard payloads.
|
||||
if(process.sandboxed!==true||process.contextIsolated!==true){require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer isolation is required');}
|
||||
// Freeze the legacy command primitive in the page realm. Document.open() removes
|
||||
// DOM listeners, but cannot replace this non-configurable prototype property.
|
||||
try {
|
||||
require('electron').contextBridge.executeInMainWorld({func:()=>{
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
// Replacement clears security listeners; these unsupported legacy APIs fail closed.
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
}});
|
||||
} catch {
|
||||
require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');
|
||||
window.stop();
|
||||
throw Error('Managed clipboard primitive could not be protected');
|
||||
}
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true);
|
||||
window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true);
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
function clipboardDOM(mode,url,token,operation,payload,limit){
|
||||
if(mode==='discard'){if(globalThis.__safetynetClipboardSnapshot?.token===token)delete globalThis.__safetynetClipboardSnapshot;return {completed:true};}
|
||||
if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed');
|
||||
const field=document.activeElement;
|
||||
const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly;
|
||||
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
|
||||
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;};
|
||||
const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;};
|
||||
if(mode==='stage'){
|
||||
const snapshot={token,operation,field};let text='';
|
||||
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
|
||||
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
|
||||
snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
|
||||
if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported');
|
||||
text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end);
|
||||
}else{
|
||||
if(operation!=='copy')throw Error('Clipboard edit target unsupported');
|
||||
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
|
||||
const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
|
||||
if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported');
|
||||
snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString();
|
||||
}
|
||||
if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit');
|
||||
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text};
|
||||
}
|
||||
const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot;
|
||||
if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable');
|
||||
if(snapshot.kind==='field'){
|
||||
const e=snapshot.field;if(e!==document.activeElement||!e.isConnected||e.value!==snapshot.value||e.selectionStart!==snapshot.start||e.selectionEnd!==snapshot.end||!editable(e)||sensitive(e)||!visible(e))throw Error('Clipboard target changed');
|
||||
if(mode!=='validate'&&operation!=='copy'){
|
||||
const value=operation==='paste'?payload:'';if(typeof value!=='string'||value.length>limit)throw Error('Clipboard payload denied');
|
||||
e.setRangeText(value,snapshot.start,snapshot.end,'end');e.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
}
|
||||
}else{
|
||||
const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range;
|
||||
if(!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||range.toString()!==snapshot.text)throw Error('Clipboard selection changed');
|
||||
const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
|
||||
if(!rangeSafe(range))throw Error('Clipboard selection changed');
|
||||
}
|
||||
return {completed:true};
|
||||
}
|
||||
const clipboardScript=(mode,{url,token,operation,text='',limit})=>`(${clipboardDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)},${JSON.stringify(operation)},${JSON.stringify(text)},${JSON.stringify(limit)})`;
|
||||
module.exports={clipboardScript};
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){
|
||||
let pending=null;
|
||||
const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();};
|
||||
async function run(operation,webContents){
|
||||
const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false;
|
||||
const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};};
|
||||
const work=async()=>{try{
|
||||
if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied');
|
||||
const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes);
|
||||
if(!limit)return denied('clipboard-policy-unavailable');
|
||||
const snapshot=await stage({operation,token,url:c.url,limit,webContents:c.webContents});
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(operation==='paste')text=await readClipboard(limit);else text=snapshot.text;
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(typeof text!=='string'||Buffer.byteLength(text)>limit||text.includes('\0'))return denied('clipboard-size-limit');
|
||||
const verdict=await inspect(Buffer.from(text),c.policy,operation);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed');
|
||||
// Persist authorization before side effects; never audit raw clipboard text.
|
||||
await audit(`clipboard.${operation}`);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
|
||||
if(operation==='cut'){
|
||||
const validated=await validate(args);
|
||||
if(validated?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
||||
await writeClipboard(text);
|
||||
if(!current(c)||expired)return denied('clipboard-context-changed');
|
||||
}
|
||||
const result=await commit(args);
|
||||
if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
|
||||
if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');}
|
||||
return {completed:true};
|
||||
}finally{try{await discard?.({token,webContents:c.webContents});}catch{}}};
|
||||
try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);}
|
||||
catch{return denied('clipboard-inspection-failed');}
|
||||
finally{clearTimeout(timer);text='';}
|
||||
}
|
||||
function perform(operation,webContents){if(pending)return Promise.resolve({completed:false,ruleId:'clipboard-busy'});const task=run(operation,webContents);pending=task;task.finally(()=>{if(pending===task)pending=null;}).catch(()=>{});return task;}
|
||||
return {perform,isPending:()=>!!pending};
|
||||
}
|
||||
module.exports={createClipboardGuard};
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
function developerShortcut(input){return input?.type==='keyDown'&&(input.key==='F12'||((input.control||input.meta)&&input.shift&&!input.alt&&['i','j'].includes(String(input.key).toLowerCase())));}
|
||||
function developerAssetRequestAllowed(details,{ready,policy,tabs}={}){
|
||||
if(!ready||policy?.browser?.allowDevTools!==true||Date.parse(policy.expiresAt)<=Date.now()||!Number.isSafeInteger(details?.webContentsId))return false;
|
||||
let url;try{url=new URL(details.url);}catch{return false;}
|
||||
if(url.protocol!=='devtools:'||url.hostname!=='devtools'||!url.pathname.startsWith('/bundled/')||url.username||url.password)return false;
|
||||
return tabs?.some(tab=>{if(tab.view.webContents.isDestroyed())return false;const frontend=tab.security?.developerFrontend||tab.view.webContents.devToolsWebContents;return frontend&&!frontend.isDestroyed()&&frontend.id===details.webContentsId;})===true;
|
||||
}
|
||||
function createDeveloperToolsGuard({contents,getContext,isCurrent,audit,createFrontend}={}){
|
||||
if(!contents||[getContext,isCurrent,audit].some(fn=>typeof fn!=='function'))throw TypeError('Native developer-tools callbacks required');
|
||||
let opening=false,openedState=false,frontend;
|
||||
const disposeFrontend=()=>{const previous=frontend;frontend=null;if(previous&&!previous.isDestroyed())previous.destroy();};
|
||||
const close=()=>{openedState=false;if(!contents.isDestroyed())contents.closeDevTools();disposeFrontend();};
|
||||
const allowed=context=>!!context?.ready&&context.foreground===true&&context.policy?.browser?.allowDevTools===true&&Date.parse(context.policy.expiresAt)>Date.now()&&/^https?:\/\//.test(context.url||'')&&!contents.isDestroyed();
|
||||
async function toggle(){
|
||||
if(contents.isDestroyed())return;
|
||||
if(openedState||contents.isDevToolsOpened()){close();await audit('devtools.closed');return;}
|
||||
const context=getContext();
|
||||
if(!allowed(context)||opening){await audit('devtools.denied');return;}
|
||||
opening=true;
|
||||
try{
|
||||
// Publication and activation changes across audit persistence invalidate this grant.
|
||||
await audit('devtools.open-requested');
|
||||
if(!allowed(getContext())||!isCurrent(context)){await audit('devtools.denied');return;}
|
||||
if(createFrontend){frontend=createFrontend();contents.setDevToolsWebContents(frontend.webContents);frontend.once('closed',()=>{if(!contents.isDestroyed()&&(openedState||contents.isDevToolsOpened()))contents.closeDevTools();});}
|
||||
contents.openDevTools({mode:'detach',activate:true,title:'S Browser · Privileged developer tools'});
|
||||
}finally{opening=false;}
|
||||
}
|
||||
const opened=()=>{openedState=true;const context=getContext();if(!allowed(context))close();else {frontend?.show();Promise.resolve(audit('devtools.opened')).catch(close);}};
|
||||
const closed=()=>{openedState=false;disposeFrontend();};
|
||||
contents.on('devtools-opened',opened);
|
||||
contents.on('devtools-closed',closed);
|
||||
contents.once('destroyed',disposeFrontend);
|
||||
return {handleInput:(event,input)=>{if(!developerShortcut(input))return false;event.preventDefault();toggle().catch(close);return true;},toggle,close,isOpened:()=>openedState,dispose:()=>{contents.removeListener('devtools-opened',opened);contents.removeListener('devtools-closed',closed);close();}};
|
||||
}
|
||||
module.exports={createDeveloperToolsGuard,developerShortcut,developerAssetRequestAllowed};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
const {AsyncLocalStorage}=require('node:async_hooks');
|
||||
const {performance}=require('node:perf_hooks');
|
||||
const {randomInt}=require('node:crypto');
|
||||
const http=require('node:http'),https=require('node:https');
|
||||
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler}=require('@opentelemetry/sdk-trace');
|
||||
const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs');
|
||||
const {resourceFromAttributes}=require('@opentelemetry/resources');
|
||||
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api');
|
||||
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security']);
|
||||
const operations=new Set(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','click','type','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
|
||||
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
|
||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value;
|
||||
if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value;
|
||||
else if(['policyVersion','bytes','count'].includes(key)&&Number.isSafeInteger(value)&&value>=0)out[key==='policyVersion'?'safetynet.policy.version':`safetynet.${key}`]=value;
|
||||
else if(key==='durationMs'&&Number.isFinite(value)&&value>=0&&value<=86400000)out['safetynet.duration.ms']=value;
|
||||
else if(key==='operation'&&operations.has(value))out['safetynet.operation']=value;
|
||||
else if(key==='component'&&components.has(value))out['safetynet.component']=value;
|
||||
else if(key==='decision'&&decisions.has(value))out['dlp.decision']=value;
|
||||
else if(key==='status'&&statuses.has(value))out['safetynet.status']=value;
|
||||
else if(key==='requestType'&&requestTypes.has(value))out['safetynet.request.type']=value;
|
||||
}return out;}
|
||||
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
|
||||
function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;}
|
||||
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
|
||||
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0;
|
||||
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
|
||||
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
|
||||
function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
|
||||
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0};
|
||||
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
|
||||
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
|
||||
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory});
|
||||
const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode});
|
||||
const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs};
|
||||
current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]});
|
||||
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
|
||||
}catch{generation=null;retire(current);return false;}
|
||||
}
|
||||
function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return;
|
||||
current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input)},...(parent?.generation===current?{context:trace.setSpan(ROOT_CONTEXT,parent.span)}:{})});
|
||||
}catch{}
|
||||
}
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
||||
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||
return {configure,span,event,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
const {safeFilename:filename,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const {createHash}=require('node:crypto');
|
||||
function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,writeFile,audit,status}){
|
||||
let busy=false;
|
||||
return async function download({url,name,method,webContentsId}){
|
||||
const context=getContext(webContentsId);let bytes,facts;
|
||||
const auditFacts=()=>facts?{bytes:facts.size,detectedType:facts.detectedType,sha256:facts.sha256}:{};
|
||||
const deny=async reason=>{await audit('download.block',reason,new URL(url).hostname,auditFacts());status?.(`Download blocked: ${reason}`);return {released:false,reason};};
|
||||
if(busy)return deny('download-busy');
|
||||
if(!context||context.webContentsId!==webContentsId)return deny('unmanaged-download');
|
||||
if(!context.ready||!context.policy?.browser.allowDownloads)return deny('downloads-disabled');
|
||||
const yara=context.policy.security?.yara,icap=context.policy.enterprise?.icap;
|
||||
const hasYara=!!yara?.enabled&&!!yara.rules.length,hasIcap=!!icap?.enabled&&icap.scanDownloads!==false;
|
||||
if(yara?.enabled&&!hasYara)return deny('yara-required');
|
||||
if(!hasYara&&!hasIcap&&!context.policy.fileTransfers?.download?.allowWithoutMalwareScanner)return deny('malware-scanner-required');
|
||||
if(method!=='GET'||!/^https?:\/\//.test(url))return deny('unsupported-download');
|
||||
if(!isCurrent(context))return deny('policy-changed');
|
||||
busy=true;const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),45000);
|
||||
try{
|
||||
const response=await fetchResponse(url,{signal:controller.signal,cache:'no-store',credentials:'include',redirect:'error'});
|
||||
if(!response.ok||!response.body)return deny('download-fetch-failed');
|
||||
const limit=Math.min(hasYara?yara.maxScanBytes:20*1024*1024,hasIcap?icap.maxBytes:20*1024*1024,context.policy.fileTransfers?.download?.maxBytes??20*1024*1024);
|
||||
const length=response.headers.get('content-length');if(length&&Number(length)>limit)return deny('download-size-limit');
|
||||
const reader=response.body.getReader(),chunks=[];let size=0;
|
||||
try{for(;;){const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>limit){await reader.cancel();return deny('download-size-limit');}chunks.push(Buffer.from(value));}}
|
||||
finally{reader.releaseLock();}
|
||||
bytes=Buffer.concat(chunks);const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
facts=inspectFileTransfer(bytes,context.policy,'download',{filename:name,mime:response.headers.get('content-type')});if(facts.decision!=='allow')return deny(facts.ruleId);
|
||||
const result=await scan(bytes,context.policy);
|
||||
if(result.decision!=='allow'||result.coverage?.complete!==true||result.sha256!==digest)return deny(result.ruleId||'yara-scan-incomplete');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
const scanLabel=hasYara&&hasIcap?'yara-icap-allow':hasYara?'yara-allow':hasIcap?'icap-allow':'explicit-no-antivirus';
|
||||
await audit('download.scan',scanLabel,new URL(url).hostname,auditFacts());
|
||||
const selected=await choosePath(filename(name));if(!selected)return {released:false,reason:'user-cancelled'};
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
// The only file-writing operation occurs after verified scanning of these exact bytes.
|
||||
await writeFile(selected,bytes,()=>isCurrent(context)&&!controller.signal.aborted);
|
||||
await audit('download.released',scanLabel,new URL(url).hostname,auditFacts());status?.('Download scanned and saved.');return {released:true,sha256:digest,facts};
|
||||
}catch{return deny('download-inspection-failed');}
|
||||
finally{clearTimeout(timeout);bytes?.fill(0);busy=false;}
|
||||
};
|
||||
}
|
||||
module.exports={createDownloadGate,filename};
|
||||
@@ -0,0 +1,20 @@
|
||||
const {app}=require('electron');
|
||||
const {spawn}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
function capabilityCrypto(operation,input){
|
||||
const root=path.join(__dirname,'../..');
|
||||
const python=app.isPackaged?path.join(process.resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe');
|
||||
const script=app.isPackaged?path.join(process.resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py');
|
||||
if(!['protect','unprotect'].includes(operation)||Buffer.byteLength(input)>32768)return Promise.reject(Error('Capability rejected'));
|
||||
return new Promise((resolve,reject)=>{
|
||||
const environment={...process.env};for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SAFETYNET_PROXY_PASSWORD'])delete environment[key];
|
||||
const child=spawn(python,[script,operation],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:environment});let output='',settled=false;
|
||||
const timer=setTimeout(()=>{child.kill();fail();},5000);
|
||||
function fail(){if(!settled){settled=true;clearTimeout(timer);reject(Error('Windows capability protection failed'));}}
|
||||
child.on('error',fail);child.stdin.on('error',fail);child.stderr.on('data',()=>{});
|
||||
child.stdout.on('data',data=>{output+=data;if(output.length>65536){child.kill();fail();}});
|
||||
child.on('close',code=>{if(code!==0||!output)return fail();if(!settled){settled=true;clearTimeout(timer);resolve(output);}});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
module.exports={capabilityCrypto};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
const fs=require('node:fs');
|
||||
const {createHash}=require('node:crypto');
|
||||
const {detectType,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const CHANNELS={upload:'scanUploads',download:'scanDownloads',clipboard:'scanClipboard',ai:'scanAI'};
|
||||
function icapRequired(policy,channel){if(!Object.hasOwn(CHANNELS,channel))throw Error('Unsupported enterprise inspection channel');const settings=policy.enterprise?.icap;return !!settings?.enabled&&settings[CHANNELS[channel]]!==false;}
|
||||
function deploymentTLS(){
|
||||
const options={};for(const [key,name] of [['ca','SAFETYNET_ICAP_CA_FILE'],['cert','SAFETYNET_ICAP_CERT_FILE'],['key','SAFETYNET_ICAP_KEY_FILE']]){if(process.env[name]){const stat=fs.statSync(process.env[name]);if(!stat.isFile()||stat.size>131072)throw Error('Enterprise scanner certificate configuration unavailable');options[key]=fs.readFileSync(process.env[name]);}}
|
||||
if(!!options.cert!==!!options.key)throw Error('Enterprise scanner client identity incomplete');return options;
|
||||
}
|
||||
async function scanEnterprise(bytes,policy,channel,scanner){
|
||||
const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
const base={engine:'icap',sha256:digest,bytes:bytes.length,coverage:{complete:false},decision:'block',ruleId:'icap-unavailable'};
|
||||
if(!icapRequired(policy,channel))return {...base,engine:'not-required',decision:'allow',ruleId:null,coverage:{complete:true}};
|
||||
const snapshot=Buffer.from(bytes);
|
||||
try{
|
||||
const result=await (scanner||require('./icap-client.cjs').scanIcap)(snapshot,policy.enterprise.icap,deploymentTLS());
|
||||
if(result.decision==='allow'&&result.coverage?.complete===true&&result.sha256===digest&&result.bytes===snapshot.length&&createHash('sha256').update(snapshot).digest('hex')===digest)return {...base,...result};
|
||||
return {...base,ruleId:typeof result.ruleId==='string'&&/^icap-[a-z0-9-]{1,80}$/.test(result.ruleId)?result.ruleId:'icap-inspection-incomplete'};
|
||||
}catch{return base;}
|
||||
finally{snapshot.fill(0);}
|
||||
}
|
||||
async function scanDownloadEnterprise(bytes,policy,localInspector,scanner){
|
||||
const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
const blocked=ruleId=>({decision:'block',ruleId,engine:'enterprise',sha256:digest,bytes:bytes.length,coverage:{complete:false}});
|
||||
const fileVerdict=inspectFileTransfer(bytes,policy,'download');if(fileVerdict.decision!=='allow')return blocked(fileVerdict.ruleId);
|
||||
const noAv=!policy.security?.yara.enabled&&!icapRequired(policy,'download');
|
||||
if(noAv&&!policy.fileTransfers?.download?.allowWithoutMalwareScanner)return blocked('malware-scanner-required');
|
||||
// Antivirus clean does not classify company secrets. Supported documents always
|
||||
// require the contained DLP parser; explicit no-antivirus mode still requires DLP.
|
||||
if(noAv||policy.dlp?.semantic?.enabled||['pdf','zip','png','jpeg','gif','webp','text'].includes(detectType(bytes))){
|
||||
if(!localInspector?.inspect)return blocked('download-dlp-unavailable');
|
||||
let dlp;try{dlp=await localInspector.inspect(bytes,policy);}catch{return blocked('download-dlp-unavailable');}
|
||||
if(dlp.decision!=='allow')return blocked(typeof dlp.ruleId==='string'?dlp.ruleId:'download-dlp-blocked');
|
||||
if(dlp.coverage?.complete!==true)return blocked('download-dlp-incomplete');
|
||||
}
|
||||
if(noAv)return {decision:'allow',ruleId:null,engine:'explicit-no-antivirus',sha256:digest,bytes:bytes.length,coverage:{complete:true}};
|
||||
if(policy.security?.yara.enabled){
|
||||
if(!localInspector)return blocked('yara-unavailable');
|
||||
let local;try{local=await localInspector.scanDownload(bytes,policy);}catch{return blocked('yara-unavailable');}
|
||||
if(local.decision!=='allow')return local;
|
||||
if(local.coverage?.complete!==true||local.sha256!==digest||local.bytes!==bytes.length)return blocked('yara-inspection-incomplete');
|
||||
if(!icapRequired(policy,'download'))return local;
|
||||
}
|
||||
return scanEnterprise(bytes,policy,'download',scanner);
|
||||
}
|
||||
module.exports={icapRequired,scanEnterprise,scanDownloadEnterprise,deploymentTLS};
|
||||
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
// RFC 3507, full-message ICAP only. Adapted objects are never released as originals.
|
||||
// TLS follows the endpoint trust chain and hostname; deployment credentials stay local.
|
||||
const net = require('node:net');
|
||||
const tls = require('node:tls');
|
||||
const crypto = require('node:crypto');
|
||||
const HEADER_LIMIT = 32768, ABSOLUTE_LIMIT = 32 * 1024 * 1024;
|
||||
function configuration(config) {
|
||||
const url = new URL(config.url);
|
||||
if (!['icap:', 'icaps:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || /[\r\n]/.test(config.url)) throw Error('configuration');
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
const loopback = host === 'localhost' || host === '::1' || (net.isIP(host) === 4 && host.split('.')[0] === '127');
|
||||
if (url.protocol === 'icap:' && !loopback && config.allowInsecureTransport !== true) throw Error('configuration');
|
||||
if (!['RESPMOD', 'REQMOD'].includes(config.method || 'RESPMOD')) throw Error('configuration');
|
||||
const maxBytes = config.maxBytes ?? ABSOLUTE_LIMIT, timeoutMs = config.timeoutMs ?? 10000;
|
||||
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > ABSOLUTE_LIMIT || !Number.isInteger(timeoutMs) || timeoutMs < 50 || timeoutMs > 120000) throw Error('configuration');
|
||||
const port = Number(url.port || (url.protocol === 'icaps:' ? 11344 : 1344));
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('configuration');
|
||||
return {url, host, connectHost:host === 'localhost' ? '127.0.0.1' : host, port, method: config.method || 'RESPMOD', maxBytes, timeoutMs, allow204: config.allow204 === true};
|
||||
}
|
||||
function headers(bytes) {
|
||||
const lines = bytes.toString('latin1').split('\r\n');
|
||||
const match = /^ICAP\/1\.0 ([0-9]{3})(?: [\x20-\x7e]*)?$/.exec(lines.shift());
|
||||
if (!match) throw Error('protocol');
|
||||
const values = Object.create(null);
|
||||
for (const line of lines) {
|
||||
const header = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([\x20-\x7e\t]*)$/.exec(line);
|
||||
if (!header) throw Error('protocol');
|
||||
const name = header[1].toLowerCase();
|
||||
if (Object.hasOwn(values, name)) throw Error('protocol');
|
||||
values[name] = header[2].trim();
|
||||
}
|
||||
return {status: Number(match[1]), values};
|
||||
}
|
||||
function exchange(c, request, deadline, credentials = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let socket, buffered = Buffer.alloc(0), finished = false, sent = false;
|
||||
const finish = (error, response) => { if (finished) return; finished = true; clearTimeout(timer); socket?.destroy(); error ? reject(Error('ICAP unavailable')) : resolve(response); };
|
||||
const timer = setTimeout(() => finish(true), Math.max(1, deadline - Date.now()));
|
||||
const connect = () => socket.write(request, error => { if (error) finish(true); else sent = true; });
|
||||
try {
|
||||
socket = c.url.protocol === 'icaps:' ? tls.connect({host:c.connectHost, port:c.port, servername:net.isIP(c.host) ? undefined : c.host, rejectUnauthorized:true, minVersion:'TLSv1.2', ca:credentials.ca, cert:credentials.cert, key:credentials.key}, connect) : net.connect({host:c.connectHost,port:c.port}, connect);
|
||||
socket.on('error', () => finish(true));
|
||||
socket.on('end', () => finish(true));
|
||||
socket.on('data', chunk => {
|
||||
try {
|
||||
if (buffered.length + chunk.length > HEADER_LIMIT) throw Error('protocol');
|
||||
buffered = Buffer.concat([buffered,chunk]);
|
||||
const boundary = buffered.indexOf('\r\n\r\n');
|
||||
if (boundary < 0) return;
|
||||
const response = headers(buffered.subarray(0,boundary));
|
||||
// No preview is sent: a premature verdict cannot attest complete inspection.
|
||||
if (!sent || response.status === 100) throw Error('protocol');
|
||||
const bodyBytes = buffered.length - boundary - 4;
|
||||
// Adaptations are blocked immediately; do not consume arbitrary vendor bodies.
|
||||
if (response.status === 204 && bodyBytes !== 0) throw Error('protocol');
|
||||
finish(false,response);
|
||||
} catch { finish(true); }
|
||||
});
|
||||
} catch { finish(true); }
|
||||
});
|
||||
}
|
||||
function request(c, method, encapsulation, body = Buffer.alloc(0)) {
|
||||
const allow = method !== 'OPTIONS' && c.allow204 ? 'Allow: 204\r\n' : '';
|
||||
return Buffer.concat([Buffer.from(`${method} ${c.url.href} ICAP/1.0\r\nHost: ${c.url.host}\r\nUser-Agent: S-Browser/1\r\nConnection: close\r\n${allow}Encapsulated: ${encapsulation}\r\n\r\n`,'ascii'),body]);
|
||||
}
|
||||
async function negotiate(c, deadline, options) {
|
||||
const response = await exchange(c,request(c,'OPTIONS','null-body=0'),deadline,options);
|
||||
if (response.status !== 200 || response.values.encapsulated !== 'null-body=0' || !response.values.istag || !response.values.methods?.split(/\s*,\s*/).includes(c.method)) throw Error('unsupported');
|
||||
return response.values.allow?.split(/\s*,\s*/).includes('204') === true;
|
||||
}
|
||||
async function probeIcap(config, options = {}) {
|
||||
try { const c = configuration(config); const allow204 = await negotiate(c,Date.now()+c.timeoutMs,options); return {ready:!c.allow204 || allow204,engine:'icap',method:c.method,tls:c.url.protocol === 'icaps:',allow204}; }
|
||||
catch { return {ready:false,engine:'icap'}; }
|
||||
}
|
||||
async function scanIcap(input, config, options = {}) {
|
||||
// Snapshot before any await: the approval is bound to exactly these bytes.
|
||||
const bytes = Buffer.from(input);
|
||||
const result = (decision,ruleId,complete) => ({decision,ruleId,engine:'icap',sha256:crypto.createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length,coverage:{complete}});
|
||||
try {
|
||||
const c = configuration(config);
|
||||
if (bytes.length > c.maxBytes) return result('block','icap-oversize',false);
|
||||
const deadline = Date.now()+c.timeoutMs;
|
||||
const negotiated = await negotiate(c,deadline,options);
|
||||
if (c.allow204 && !negotiated) return result('block','icap-capability',false);
|
||||
// Synthetic metadata avoids sending usernames, private URLs or filenames.
|
||||
const http = Buffer.from(c.method === 'RESPMOD' ? `HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n` : `POST /scan/object HTTP/1.1\r\nHost: inspection.invalid\r\nContent-Type: application/octet-stream\r\nContent-Length: ${bytes.length}\r\n\r\n`,'ascii');
|
||||
const chunk = bytes.length ? Buffer.concat([Buffer.from(bytes.length.toString(16)+'\r\n'),bytes,Buffer.from('\r\n0\r\n\r\n')]) : Buffer.from('0\r\n\r\n');
|
||||
const prefix = c.method === 'RESPMOD' ? 'res' : 'req';
|
||||
const response = await exchange(c,request(c,c.method,`${prefix}-hdr=0, ${prefix}-body=${http.length}`,Buffer.concat([http,chunk])),deadline,options);
|
||||
const flagged = Object.keys(response.values).some(name => ['x-infection-found','x-virus-id','x-violations-found','x-block-reason'].includes(name));
|
||||
if (flagged) return result('block','icap-threat',false);
|
||||
if (response.status === 204 && c.allow204 && negotiated && response.values.istag && (!response.values.encapsulated || response.values.encapsulated === 'null-body=0')) return result('allow','icap-clean',true);
|
||||
return result('block',response.status === 200 ? 'icap-adaptation' : 'icap-verdict',false);
|
||||
} catch { return result('block','icap-unavailable',false); }
|
||||
}
|
||||
module.exports = {scanIcap,probeIcap};
|
||||
@@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
<path d="M12 19V5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-book-open"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5v16" />
|
||||
<path d="M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v6" />
|
||||
<path d="M15 10H9" />
|
||||
<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bot"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 8V4H8" />
|
||||
<rect width="16" height="12" x="4" y="8" rx="2" />
|
||||
<path d="M2 14h2" />
|
||||
<path d="M20 14h2" />
|
||||
<path d="M15 13v2" />
|
||||
<path d="M9 13v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-chevron-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-ellipsis"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-folder"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-globe"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-grid-2x2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3v18" />
|
||||
<path d="M3 12h18" />
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-key-round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-lock-keyhole"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<rect x="3" y="10" width="18" height="12" rx="2" />
|
||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-maximize-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="m21 3-7 7" />
|
||||
<path d="m3 21 7-7" />
|
||||
<path d="M9 21H3v-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-minus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-panel-left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-panel-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M15 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 331 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-rotate-cw"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,21 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-scan-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v2" />
|
||||
<path d="M21 17v2a2 2 0 0 1-2 2h-2" />
|
||||
<path d="M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M7 8h8" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 16h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 515 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-search"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-share"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v13" />
|
||||
<path d="m16 6-4-4-4 4" />
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-shield-check"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sparkles"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" />
|
||||
<path d="M20 2v4" />
|
||||
<path d="M22 4h-4" />
|
||||
<circle cx="4" cy="20" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-squares-unite"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3a1 1 0 0 0 1 1h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-3a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,15 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-star"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v1.55.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-x"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,481 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>
|
||||
S Browser
|
||||
</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body data-theme="auto" data-tab-mode="separate">
|
||||
|
||||
<!-- Trusted browser chrome: native layout uses this measured height. -->
|
||||
<header id="chrome">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-leading">
|
||||
<div class="traffic-lights" aria-label="Window controls">
|
||||
<button id="window-close" class="traffic close" aria-label="Close window" title="Close">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
<button id="window-minimize" class="traffic minimize" aria-label="Minimize window" title="Minimize">
|
||||
<img src="icons/minus.svg" alt="">
|
||||
</button>
|
||||
<button id="window-maximize" class="traffic maximize" aria-label="Maximize or restore window" title="Maximize">
|
||||
<img src="icons/maximize-2.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button id="sidebar-toggle" class="icon-button" title="Show sidebar" aria-label="Toggle sidebar" aria-expanded="false">
|
||||
<img src="icons/panel-left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" id="back" class="icon-button" aria-label="Back" title="Back">
|
||||
<img src="icons/chevron-left.svg" alt="">
|
||||
</button>
|
||||
<button type="button" id="forward" class="icon-button" aria-label="Forward" title="Forward">
|
||||
<img src="icons/chevron-right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<form id="address-form">
|
||||
<img class="address-lock" src="icons/globe.svg" alt="">
|
||||
<input id="address" aria-label="Website address" aria-describedby="managed" placeholder="Enter website name or address" autocomplete="off" spellcheck="false">
|
||||
<button type="button" id="reload" class="icon-button" aria-label="Reload" title="Reload">
|
||||
<img src="icons/rotate-cw.svg" alt="">
|
||||
</button>
|
||||
<button type="submit" class="visually-hidden">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
<div class="toolbar-trailing">
|
||||
<button id="share-url" class="icon-button" title="Share page link" aria-label="Share page link">
|
||||
<img src="icons/share.svg" alt="">
|
||||
</button>
|
||||
<button id="new-tab" class="icon-button" title="New tab" aria-label="New tab">
|
||||
<img src="icons/plus.svg" alt="">
|
||||
</button>
|
||||
<button id="tab-overview" class="icon-button" title="Tab overview" aria-label="Tab overview" aria-expanded="false">
|
||||
<img src="icons/squares-unite.svg" alt="">
|
||||
</button>
|
||||
<button id="quick-ai" class="icon-button" hidden disabled title="AI workspace" aria-label="AI workspace"><img src="icons/sparkles.svg" alt=""></button>
|
||||
<button id="quick-passwords" class="icon-button" hidden disabled title="Passwords" aria-label="Passwords"><img src="icons/key-round.svg" alt=""></button>
|
||||
<button id="quick-inspect" class="icon-button" hidden disabled title="Inspect document locally" aria-label="Inspect document locally"><img src="icons/scan-text.svg" alt=""></button>
|
||||
<button id="quick-agents" class="icon-button" hidden disabled title="Local agents" aria-label="Local agents"><img src="icons/bot.svg" alt=""></button>
|
||||
<button id="tools-toggle" hidden disabled class="icon-button" title="Browser tools and appearance" aria-label="Browser tools and appearance" aria-expanded="false" aria-controls="tools-menu">
|
||||
<img src="icons/ellipsis.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-row">
|
||||
<div id="tab-strip" aria-label="Saved browsing tabs">
|
||||
<button class="browser-tab active">
|
||||
Start Page
|
||||
</button>
|
||||
</div>
|
||||
<div class="security-state">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
<span id="managed">
|
||||
Enrollment required
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="tools-menu" class="glass-popover" hidden>
|
||||
<p class="menu-caption">
|
||||
WORKSPACE TOOLS
|
||||
</p>
|
||||
<button id="ai-toggle" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/sparkles.svg" alt="">
|
||||
AI workspace
|
||||
</button>
|
||||
<button id="sidebar" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/panel-right.svg" alt="">
|
||||
Toggle AI sidebar
|
||||
</button>
|
||||
<button id="passwords" class="menu-item" aria-expanded="false">
|
||||
<img src="icons/key-round.svg" alt="">
|
||||
Passwords
|
||||
</button>
|
||||
<button id="inspect" class="menu-item" title="Inspect document locally">
|
||||
<img src="icons/scan-text.svg" alt="">
|
||||
Inspect document / media
|
||||
</button>
|
||||
<button id="agents" class="menu-item">
|
||||
<img src="icons/bot.svg" alt="">
|
||||
Local agents
|
||||
</button>
|
||||
<button id="bookmark" class="menu-item">
|
||||
<img src="icons/bookmark-plus.svg" alt="">
|
||||
Bookmark this page
|
||||
</button>
|
||||
<button id="reading-add" class="menu-item">
|
||||
<img src="icons/book-open.svg" alt="">
|
||||
Save to Reading List
|
||||
</button>
|
||||
<section id="appearance-section" hidden>
|
||||
<div class="menu-divider">
|
||||
</div>
|
||||
<p class="menu-caption">APPEARANCE</p>
|
||||
<div id="settings">
|
||||
<label for="theme-mode">
|
||||
Theme
|
||||
</label>
|
||||
<select id="theme-mode">
|
||||
<option value="auto">
|
||||
System
|
||||
</option>
|
||||
<option value="light">
|
||||
Light
|
||||
</option>
|
||||
<option value="dark">
|
||||
Dark
|
||||
</option>
|
||||
</select>
|
||||
<label for="accent-mode">Accent</label>
|
||||
<select id="accent-mode">
|
||||
<option value="blue">Blue</option>
|
||||
<option value="teal">Teal</option>
|
||||
<option value="violet">Violet</option>
|
||||
</select>
|
||||
<label for="tab-mode">
|
||||
Tab layout
|
||||
</label>
|
||||
<select id="tab-mode">
|
||||
<option value="separate">
|
||||
Separate
|
||||
</option>
|
||||
<option value="compact">
|
||||
Compact
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p id="settings-managed" class="appearance-status" hidden>Appearance is set by your administrator.</p>
|
||||
<p id="theme-status" class="appearance-status" aria-live="polite">Follows your system appearance.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Local sidebar metadata; remote website content is a separate native view. -->
|
||||
<aside id="library" hidden>
|
||||
<div class="library-brand">
|
||||
<div class="brand-monogram">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<strong id="brand">
|
||||
S Browser
|
||||
</strong>
|
||||
<span>
|
||||
Managed workspace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav aria-label="Browser library">
|
||||
<details class="library-section" open>
|
||||
<summary class="library-item">
|
||||
<img src="icons/star.svg" alt="">
|
||||
Favorites
|
||||
</summary>
|
||||
<div id="library-favorites" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<details class="library-section">
|
||||
<summary class="library-item">
|
||||
<img src="icons/bookmark.svg" alt="">
|
||||
Bookmarks
|
||||
</summary>
|
||||
<div id="library-bookmarks" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<details class="library-section">
|
||||
<summary class="library-item">
|
||||
<img src="icons/book-open.svg" alt="">
|
||||
Reading List
|
||||
</summary>
|
||||
<div id="library-reading" class="library-links">
|
||||
</div>
|
||||
</details>
|
||||
<div class="library-heading">
|
||||
<span>
|
||||
TAB GROUPS
|
||||
</span>
|
||||
</div>
|
||||
<div class="group-controls">
|
||||
<input id="group-name" placeholder="New group name" aria-label="New tab group name" maxlength="80" autocomplete="off">
|
||||
<button id="group-add" class="icon-button" aria-label="Add tab group">
|
||||
<img src="icons/plus.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<div id="library-groups">
|
||||
</div>
|
||||
<div class="library-heading">
|
||||
<span>
|
||||
OPEN TABS
|
||||
</span>
|
||||
</div>
|
||||
<div id="library-tabs">
|
||||
</div>
|
||||
</nav>
|
||||
<div class="library-footer">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
<span>
|
||||
Managed browser workspace
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Trusted start page, shown only while the native website view is hidden. -->
|
||||
<main id="start-page" hidden>
|
||||
<div class="start-content">
|
||||
<p class="start-eyebrow">
|
||||
S BROWSER
|
||||
</p>
|
||||
<h1>
|
||||
Your space to explore.
|
||||
</h1>
|
||||
<section class="start-section">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
Favorites
|
||||
</h2>
|
||||
<span>
|
||||
Keep what matters close.
|
||||
</span>
|
||||
</div>
|
||||
<div id="favorites-grid" class="favorites-grid">
|
||||
</div>
|
||||
<p id="favorites-empty" hidden>
|
||||
Bookmark a page to add it to your favorites.
|
||||
</p>
|
||||
</section>
|
||||
<section class="start-section">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
Recently visited
|
||||
</h2>
|
||||
</div>
|
||||
<div id="recent-list" class="recent-list">
|
||||
</div>
|
||||
</section>
|
||||
<div class="privacy-card">
|
||||
<div class="privacy-icon">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
A protected workspace.
|
||||
</strong>
|
||||
<p>
|
||||
Your organization's rules follow you as you browse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="session-note">
|
||||
Each tab keeps its own managed browsing context.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Navigation-record overview; inactive records are not running isolated pages. -->
|
||||
<section id="overview" hidden aria-label="Tab overview">
|
||||
<div class="overview-heading">
|
||||
<h1>
|
||||
Your tabs
|
||||
</h1>
|
||||
<button id="overview-close" class="icon-button" aria-label="Close tab overview">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p class="overview-note">
|
||||
Pick up where you left off.
|
||||
</p>
|
||||
<div id="overview-grid">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Enterprise enrollment and paused-access state. -->
|
||||
<main id="welcome">
|
||||
<div class="card">
|
||||
<div class="welcome-mark">
|
||||
<img src="icons/shield-check.svg" alt="">
|
||||
</div>
|
||||
<p class="eyebrow">
|
||||
YOUR MANAGED WORKSPACE
|
||||
</p>
|
||||
<h1>
|
||||
A safer place to work.
|
||||
</h1>
|
||||
<p id="message">
|
||||
Connect to your organization for secure browsing and approved AI.
|
||||
</p>
|
||||
<div id="enrollment">
|
||||
<label>
|
||||
Management server
|
||||
<input id="server" value="http://127.0.0.1:4310" type="url">
|
||||
</label>
|
||||
<label>
|
||||
Enrollment code
|
||||
<input id="code" type="password" autocomplete="off" placeholder="Provided by your administrator">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="enroll">
|
||||
Connect browser
|
||||
</button>
|
||||
<button id="sso" class="secondary">
|
||||
Enterprise SSO
|
||||
</button>
|
||||
</div>
|
||||
<small>
|
||||
Your organization's management server supplies the security policy for this browser.
|
||||
</small>
|
||||
</div>
|
||||
<p id="error" role="status" aria-live="polite">
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Approved AI sidebar; retains existing security and inspection controls. -->
|
||||
<aside id="ai" hidden>
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
APPROVED BY YOUR ORGANIZATION
|
||||
</p>
|
||||
<h2>
|
||||
AI workspace
|
||||
</h2>
|
||||
</div>
|
||||
<button id="ai-close" class="icon-button" aria-label="Close AI panel">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p class="panel-description">
|
||||
Prompts are inspected before reaching your approved model. Page content is shared only when you choose.
|
||||
</p>
|
||||
<div id="conversation" aria-live="polite">
|
||||
<div class="empty-chat">
|
||||
<img src="icons/sparkles.svg" alt="">
|
||||
<h3>
|
||||
Make room for better work.
|
||||
</h3>
|
||||
<p>
|
||||
Ask a question, draft an idea, or inspect a document locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="ai-form">
|
||||
<label class="visually-hidden" for="prompt">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea id="prompt" placeholder="Ask your company AI…" maxlength="100000">
|
||||
</textarea>
|
||||
<div class="composer-footer">
|
||||
<span>
|
||||
Policy checked
|
||||
</span>
|
||||
<button id="send" type="submit">
|
||||
Send
|
||||
<img src="icons/arrow-up.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<!-- Local password-vault UI: passwords remain masked and never rendered in lists. -->
|
||||
<aside id="vault" hidden>
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
PRIVATE TO THIS DEVICE
|
||||
</p>
|
||||
<h2>
|
||||
Passwords
|
||||
</h2>
|
||||
</div>
|
||||
<button id="vault-close" class="icon-button" aria-label="Close passwords">
|
||||
<img src="icons/x.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<p id="vault-description" class="panel-description">
|
||||
Unlock your encrypted vault to use a saved login.
|
||||
</p>
|
||||
<p id="vault-error" role="status" aria-live="polite">
|
||||
</p>
|
||||
<form id="vault-access">
|
||||
<label id="master-label" for="vault-master">
|
||||
Master password
|
||||
</label>
|
||||
<input id="vault-master" type="password" autocomplete="current-password" minlength="16" maxlength="1024" required>
|
||||
<label id="vault-confirm-label" for="vault-confirm" hidden>
|
||||
Confirm master password
|
||||
</label>
|
||||
<input id="vault-confirm" type="password" autocomplete="new-password" minlength="16" maxlength="1024" hidden>
|
||||
<button id="vault-access-submit" type="submit">
|
||||
Unlock vault
|
||||
</button>
|
||||
<small id="vault-access-note">
|
||||
Use at least 16 characters. Your master password is never saved.
|
||||
</small>
|
||||
</form>
|
||||
<div id="vault-open" hidden>
|
||||
<div class="vault-actions">
|
||||
<span id="vault-state">
|
||||
Unlocked
|
||||
</span>
|
||||
<button id="vault-lock" class="secondary">
|
||||
Lock
|
||||
</button>
|
||||
</div>
|
||||
<div id="vault-list">
|
||||
</div>
|
||||
<details id="vault-add">
|
||||
<summary>
|
||||
Add a login
|
||||
</summary>
|
||||
<form id="vault-save">
|
||||
<label>
|
||||
Website origin
|
||||
<input id="vault-origin" type="url" placeholder="https://company.example" required autocomplete="off" maxlength="4096">
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input id="vault-label" placeholder="Work account" maxlength="100" autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input id="vault-username" autocomplete="off" maxlength="512">
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input id="vault-password" type="password" autocomplete="new-password" maxlength="1024" required>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="vault-generate" type="button" class="secondary">
|
||||
Generate
|
||||
</button>
|
||||
<button id="vault-save-submit" type="submit">
|
||||
Save login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<small>
|
||||
Logins fill only when you click Fill. Automatic page-load filling is disabled.
|
||||
</small>
|
||||
</div>
|
||||
</aside>
|
||||
<dialog id="bookmark-editor" aria-labelledby="bookmark-editor-title">
|
||||
<form id="bookmark-form">
|
||||
<h2 id="bookmark-editor-title">Add bookmark</h2>
|
||||
<p class="bookmark-notice">Bookmarks are managed by your company. Administrators may access them when enabled by policy.</p>
|
||||
<label>Title<input id="bookmark-title" maxlength="120" required autocomplete="off"></label>
|
||||
<label>Website URL<input id="bookmark-url" type="url" maxlength="2048" required autocomplete="off"></label>
|
||||
<label>Folder<input id="bookmark-folder" maxlength="80" autocomplete="off"></label>
|
||||
<p id="bookmark-editor-error" role="alert"></p>
|
||||
<div class="actions"><button id="bookmark-cancel" type="button" class="secondary">Cancel</button><button id="bookmark-save" type="submit">Save bookmark</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script src="renderer.js">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const {spawn}=require('node:child_process');
|
||||
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
|
||||
function privateInspectorPaths(root,packaged=false,environment=process.env){
|
||||
const python=path.resolve(root,packaged?'python/python.exe':'.runtime/windows/python/python.exe');
|
||||
const script=path.resolve(root,packaged?'inspector/server.py':'apps/inspector/server.py');
|
||||
try{
|
||||
for(const [file,override]of [[python,environment.SAFETYNET_PYTHON],[script,environment.SAFETYNET_INSPECTOR_SCRIPT]]){
|
||||
if(override&&path.resolve(override).toLowerCase()!==file.toLowerCase())return null;
|
||||
if(!fs.statSync(file).isFile())return null;
|
||||
// Do not resolve away a junction before checking ownership boundaries.
|
||||
for(let item=file;;item=path.dirname(item)){
|
||||
if(fs.lstatSync(item).isSymbolicLink())return null;
|
||||
if(path.dirname(item)===item)break;
|
||||
}
|
||||
}
|
||||
return {python,script};
|
||||
}catch{return null;}
|
||||
}
|
||||
function startInspector(root,packaged=false){
|
||||
const files=privateInspectorPaths(root,packaged);if(!files)return null;
|
||||
const {python,script}=files;
|
||||
const credential=crypto.randomBytes(32).toString('base64url');
|
||||
const semanticRuntime=path.join(root,packaged?'semantic':'.runtime/windows/semantic');
|
||||
// Native model loading remains wholly local; the Python adapter verifies pinned artifacts.
|
||||
// Explicit deployment paths take precedence, and missing artifacts fail semantic inspection closed.
|
||||
const environment={...process.env,INSPECTOR_TOKEN:credential,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)};
|
||||
if(!environment.EMBEDDING_MODEL_DIR)environment.EMBEDDING_MODEL_DIR=path.join(semanticRuntime,'models');
|
||||
if(!environment.EMBEDDING_WORKER_PATH)environment.EMBEDDING_WORKER_PATH=path.join(semanticRuntime,'searchi-gemma-worker.exe');
|
||||
for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','SIEM_HTTP_TOKEN','SIEM_KEY_FILE','SAFETYNET_PROXY_PASSWORD'])delete environment[key];
|
||||
const child=spawn(python,[script],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:environment});
|
||||
let port,output='';const startup=new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>{child.kill();reject(Error('Local inspector startup timed out'));},30000);
|
||||
child.on('error',()=>{clearTimeout(timer);reject(Error('Local inspector failed to start'));});
|
||||
child.once('exit',()=>{clearTimeout(timer);reject(Error('Local inspector stopped'));});
|
||||
child.stdout.on('data',chunk=>{output+=chunk.toString();if(output.length>4096){child.kill();return;}
|
||||
if(output.includes('\n')){try{const value=JSON.parse(output.split('\n')[0]);if(!Number.isInteger(value.port)||value.port<1||value.port>65535)throw Error();if(process.platform==='win32'&&(value.containment?.active!==true||value.containment?.kind!=='windows-job-v1'||value.containment?.killsDescendants!==true||value.containment?.parserIsolation?.active!==true||value.containment?.parserIsolation?.kind!=='windows-appcontainer-v1'||value.containment?.parserIsolation?.networkCapabilities!==0||value.containment?.parserIsolation?.privatePipes!==true||value.containment?.parserIsolation?.readOnlyRuntime!==true))throw Error();port=value.port;clearTimeout(timer);resolve();}catch{child.kill();reject(Error('Local inspector startup rejected'));}}});
|
||||
child.stderr.on('data',()=>{});
|
||||
});startup.catch(()=>{});
|
||||
const waitReady=async()=>{await startup;if(child.exitCode!==null||child.signalCode!==null)throw Error('Local content inspector stopped; restart the browser');};
|
||||
const invoke=async(route,bytes,policy)=>{
|
||||
await waitReady();
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
|
||||
try{
|
||||
const response=await require('./loopback-http.cjs').postLoopback({port,route,token:credential,body:{data:Buffer.from(bytes).toString('base64'),policy},signal:controller.signal});
|
||||
if(response.status!==200)throw Error('Local content inspection failed');const result=response.body;
|
||||
if(!['allow','block'].includes(result.decision)||typeof result.coverage?.complete!=='boolean')throw Error('Inspector response rejected');
|
||||
if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');return result;
|
||||
}catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;}
|
||||
finally{clearTimeout(timer);}
|
||||
};
|
||||
return {stop:()=>child.kill(),waitReady,inspect:(bytes,policy)=>invoke('/inspect',bytes,policy),scanDownload:(bytes,policy)=>invoke('/scan-download',bytes,policy)};
|
||||
}
|
||||
module.exports={startInspector,privateInspectorPaths};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const http=require('node:http');
|
||||
function postLoopback({port,route,token,body,signal,maxResponseBytes=2*1024*1024}){
|
||||
if(!Number.isInteger(Number(port))||Number(port)<1||Number(port)>65535||!/^\/[a-z0-9/-]+$/i.test(route)||typeof token!=='string')return Promise.reject(Error('Local endpoint rejected'));
|
||||
const payload=Buffer.from(JSON.stringify(body));
|
||||
return new Promise((resolve,reject)=>{
|
||||
// Direct TCP avoids environment/global proxy routing of private content.
|
||||
const request=http.request({hostname:'127.0.0.1',port:Number(port),path:route,method:'POST',agent:false,signal,
|
||||
headers:{authorization:`Bearer ${token}`,'content-type':'application/json','content-length':payload.length}},response=>{
|
||||
if(response.statusCode>=300&&response.statusCode<400){response.resume();reject(Error('Local redirects rejected'));return;}
|
||||
let size=0;const chunks=[];response.on('error',reject);
|
||||
response.on('data',chunk=>{size+=chunk.length;if(size>maxResponseBytes){response.destroy();request.destroy();reject(Error('Local response budget exceeded'));}else chunks.push(chunk);});
|
||||
response.on('end',()=>{try{resolve({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks).toString('utf8'))});}catch{reject(Error('Invalid local JSON response'));}});
|
||||
});
|
||||
request.on('error',reject);request.end(payload);
|
||||
});
|
||||
}
|
||||
module.exports={postLoopback};
|
||||
@@ -0,0 +1,631 @@
|
||||
const { app, BrowserWindow, WebContentsView, ipcMain, session, safeStorage, shell, dialog, powerMonitor, clipboard, Menu } = require('electron');
|
||||
const {assertSecureStartup}=require('./startup-security.cjs');
|
||||
const {assertPolicyAssignment}=require('./policy-assignment.cjs');
|
||||
assertSecureStartup(app.commandLine);
|
||||
// Force Chromium sandboxing globally before any renderer is created. The subframe
|
||||
// preload option below never grants a Node engine: native proof tests verify this.
|
||||
app.enableSandbox();
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const {createDiagnostics}=require('./diagnostics.cjs');
|
||||
const diagnostics=createDiagnostics();
|
||||
function diagnosticContext(extra={}){return {policyVersion:policy?.version||0,...(device?.id?{deviceId:device.id}:{}),...extra};}
|
||||
function diagnosticSpan(name,attributes,operation){
|
||||
// Instrumentation never replaces, skips or retries a security operation.
|
||||
let started=false;const run=()=>{started=true;return Promise.resolve(operation()).then(result=>{if(['allow','block','deny','unknown'].includes(result?.decision)){try{diagnostics.event(name,diagnosticContext({...attributes,decision:result.decision}));}catch{}}return result;});};
|
||||
try{return diagnostics.span(name,diagnosticContext(attributes),run);}catch(error){if(started)throw error;return run();}
|
||||
}
|
||||
let diagnosticsConfiguration='';
|
||||
function configureDiagnostics(config){const value=config||{enabled:false},context=diagnosticContext(),key=JSON.stringify([value,context]);if(key===diagnosticsConfiguration)return;try{diagnostics.configure(value,context);diagnosticsConfiguration=key;}catch{diagnosticsConfiguration='';try{diagnostics.configure({enabled:false});}catch{}}}
|
||||
function diagnosticCorrelation(){try{const ids=diagnostics.correlation?.();return /^[a-f0-9]{32}$/.test(ids?.traceId||'')&&/[1-9a-f]/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids?.spanId||'')&&/[1-9a-f]/.test(ids.spanId)?{traceId:ids.traceId,spanId:ids.spanId}:{};}catch{return {};}}
|
||||
let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer;
|
||||
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
|
||||
const {createTabRegistry}=require('./tab-registry.cjs');
|
||||
let tabs,tabsRevision=0;
|
||||
function activeTab(){return tabs?.active();}
|
||||
function tabContext(id,{foreground=true}={}){const tab=id?tabs.get(id):activeTab();if(!tab)throw Error('No native browser tab');return tabs.context(tab.id,{foreground});}
|
||||
function currentTabContext(token){return ready&&token?.policyVersion===policy?.version&&tabs.current(token)&&Date.parse(policy?.expiresAt)>Date.now();}
|
||||
function selectTab(id){const target=tabs.get(id);if(!target.guarded)throw Error('Native tab security is still initializing');const changed=target!==activeTab();const tab=tabs.activate(id);if(changed){win.webContents.focus();vault?.lock();vaultPanel=false;}view=tab.view;startPage=tab.startPage;pageTitle=tab.title;pageColor=tab.color;layout();return tab;}
|
||||
function blockedTab(){return tabs?.all().find(tab=>tab.security.visualBlocked||tab.security.contentBlocked);}
|
||||
function stickyBlock(){const tab=blockedTab();return tab&&(tab.security.visualBlocked||tab.security.contentBlocked);}
|
||||
const {startInspector}=require('./inspection.cjs');
|
||||
const {icapRequired,scanEnterprise,scanDownloadEnterprise}=require('./enterprise-inspection.cjs');
|
||||
const {createDownloadGate}=require('./download-gate.cjs');
|
||||
const {resolverOptions,createNetworkPosture}=require('./network-posture.cjs');
|
||||
const networkPosture=createNetworkPosture();let networkTimer,networkChecking=false;
|
||||
const {startAgentControl}=require('./agent-control.cjs');
|
||||
const {createAgentAdapters}=require('./agent-adapters.cjs');
|
||||
const {createVault}=require('./vault.cjs');
|
||||
const {createDeveloperToolsGuard,developerAssetRequestAllowed}=require('./developer-tools.cjs');
|
||||
const {settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
|
||||
const {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs');
|
||||
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='';
|
||||
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
|
||||
let appearancePreferences={};
|
||||
function managedSettings(){return settingsState({policy,ready,preferences:appearancePreferences});}
|
||||
function requireManagedSection(section){requireSection(managedSettings(),section);}
|
||||
function requireFeatureSection(section){requireFeature(managedSettings(),section);}
|
||||
function loadAppearance(){try{appearancePreferences=validateAppearance(JSON.parse(fs.readFileSync(path.join(app.getPath('userData'),'appearance.json'),'utf8')));}catch{appearancePreferences={};}}
|
||||
function persistAppearance(){fs.writeFileSync(path.join(app.getPath('userData'),'appearance.json'),JSON.stringify(appearancePreferences),{mode:0o600});}
|
||||
const {fillScript}=require('./password-fill.cjs');
|
||||
const {createVisualPhishing,describeScript}=require('./visual-phishing.cjs');
|
||||
const {fingerprintPNG}=require('../../packages/core/visual-image.cjs');
|
||||
const {hammingDistance}=require('../../packages/core/visual-hash.cjs');
|
||||
const {capturePNG}=require('./visual-capture.cjs');
|
||||
const {createClipboardGuard}=require('./clipboard-guard.cjs');
|
||||
const {clipboardScript}=require('./clipboard-dom.cjs');
|
||||
const {attachPageClipboardGuard,disposePageClipboardGuard}=require('./page-clipboard-guard.cjs');
|
||||
let clipboardGuard,clipboardCore,clipboardFatal=false;
|
||||
function installClipboardControls(contents){
|
||||
contents.on('before-input-event',(event,input)=>{
|
||||
if(input.type!=='keyDown')return;
|
||||
const key=input.key.toLowerCase(),modifier=(input.control||input.meta)&&!input.alt;
|
||||
const operation=modifier&&key==='c'||input.control&&key==='insert'?'copy':modifier&&key==='x'||input.shift&&key==='delete'?'cut':modifier&&key==='v'||input.shift&&key==='insert'?'paste':null;
|
||||
if(operation){event.preventDefault();diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{});}
|
||||
});
|
||||
contents.on('context-menu',(_event,params)=>{
|
||||
const actions=params.isEditable?['copy','cut','paste']:params.selectionText?['copy']:[];
|
||||
if(!actions.length)return;
|
||||
Menu.buildFromTemplate(actions.map(operation=>({label:operation[0].toUpperCase()+operation.slice(1),click:()=>diagnosticSpan('browser.clipboard',{operation},()=>clipboardGuard?.perform(operation,contents)).catch(()=>{})}))).popup({window:win});
|
||||
});
|
||||
}
|
||||
let managementHealthy=false;
|
||||
function visualDeadline(operation){let timeout;return Promise.race([operation,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(Error('Visual check timed out')),5000);})]).finally(()=>clearTimeout(timeout));}
|
||||
function checkVisual(...args){return diagnosticSpan('browser.visual',args[0]?.id?{sessionId:args[0].id}:{},()=>checkVisualImpl(...args));}
|
||||
async function checkVisualImpl(tab=activeTab()){
|
||||
if(!tab||tab.view.webContents.isDestroyed())return {decision:'unknown'};
|
||||
if(!policy?.security.visualPhishing.enabled)return {decision:'allow'};
|
||||
const security=tab.security;
|
||||
if(!security.visualPending){const operation=security.visualScanner.check({force:true});security.visualPending=operation;operation.finally(()=>{if(security.visualPending===operation)security.visualPending=null;}).catch(()=>{});}
|
||||
const generation=tab.navigationId,version=policy.version,url=tab.view.webContents.getURL(),result=await security.visualPending;
|
||||
if(ready&&generation===tab.navigationId&&version===policy.version&&!tab.view.webContents.isDestroyed()&&url===tab.view.webContents.getURL()&&result.decision==='allow')security.visualApproval={generation,version,url};
|
||||
if(!tab.view.webContents.isDestroyed()&&generation===tab.navigationId&&version===policy.version&&url===tab.view.webContents.getURL()){if(ready){if(result.decision==='unknown')message='Login protection could not approve this view. Credential actions are held.';emit();}return result;}
|
||||
return {decision:'unknown',reason:'stale-context'};
|
||||
}
|
||||
async function configureVisualPage(enabled,tab=activeTab()){
|
||||
if(!tab)return;tabs.navigation(tab.id);const contents=tab.view.webContents;
|
||||
if(contents.isDestroyed()||!/^https?:\/\//.test(contents.getURL()))return;
|
||||
const token=require('node:crypto').randomUUID();
|
||||
await new Promise((resolve,reject)=>{
|
||||
const handler=(event,value)=>{if(event.sender===contents&&event.senderFrame===contents.mainFrame&&value===token){clearTimeout(timeout);ipcMain.removeListener('managed:visual-configured',handler);resolve();}};
|
||||
const timeout=setTimeout(()=>{ipcMain.removeListener('managed:visual-configured',handler);enabled?reject(Error('Visual protection could not initialize')):resolve();},2000);
|
||||
ipcMain.on('managed:visual-configured',handler);contents.send('managed:visual-config',{enabled,token});
|
||||
});
|
||||
}
|
||||
let message='Enroll this browser to receive company policy.',syncTimer,localInspector,auditQueue=[],flushing=false,agentService,agentStarting=false,securityCore;
|
||||
const recentRequests=new Map();
|
||||
const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json');
|
||||
app.commandLine.appendSwitch('disable-http-cache');
|
||||
const uiUrl=pathToFileURL(path.join(__dirname,'index.html')).href;
|
||||
const configPath=()=>path.join(app.getPath('userData'),'enrollment.json');
|
||||
const cliRequest=app.commandLine.getSwitchValue('agent-action'),cliConnection=app.commandLine.getSwitchValue('agent-connection');
|
||||
if(cliRequest){try{require('./agent-client.cjs').configureClient(cliConnection);}catch{app.exit(1);}}
|
||||
const ownsBrowserInstance=!!cliRequest||app.requestSingleInstanceLock();
|
||||
if(!ownsBrowserInstance)app.quit();
|
||||
else if(!cliRequest)app.on('second-instance',()=>{if(win&&!win.isDestroyed()){if(win.isMinimized())win.restore();win.focus();}});
|
||||
function requireEncryption(){
|
||||
if(!safeStorage.isEncryptionAvailable() || (process.platform==='linux' && safeStorage.getSelectedStorageBackend()==='basic_text'))throw Error('An OS credential store is required. Configure Keychain, Windows DPAPI, or Linux Secret Service.');
|
||||
}
|
||||
function saveDevice(value){requireEncryption();fs.writeFileSync(configPath(),JSON.stringify({encryptedEnrollment:safeStorage.encryptString(JSON.stringify(value)).toString('base64')}),{mode:0o600});}
|
||||
function loadDevice(){
|
||||
if(!fs.existsSync(configPath()))return;
|
||||
requireEncryption();const d=JSON.parse(fs.readFileSync(configPath(),'utf8'));return JSON.parse(safeStorage.decryptString(Buffer.from(d.encryptedEnrollment,'base64')));
|
||||
}
|
||||
function managementUrl(value){
|
||||
const u=new URL(value);
|
||||
if(u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('Enter a management server origin without path or credentials');
|
||||
if(u.protocol!=='https:' && !(u.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(u.hostname)))throw Error('Management requires HTTPS; local development may use loopback HTTP.');
|
||||
if(process.env.SAFETYNET_MANAGEMENT_URL&&new URL(process.env.SAFETYNET_MANAGEMENT_URL).origin!==u.origin)throw Error('Enrollment must use the administrator-deployed management server');
|
||||
return u.origin;
|
||||
}
|
||||
const managementRequests=new Map();
|
||||
function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));}
|
||||
async function requestImpl(server,route,body,credential){
|
||||
if(route==='/api/device/policy'){
|
||||
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
|
||||
return require('./management-channel.cjs').protectedPolicyRequest({device,request,save:saveDevice});
|
||||
}
|
||||
const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`;
|
||||
if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1';
|
||||
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')?65000:10000);managementRequests.set(controller,route);
|
||||
try{
|
||||
const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch;
|
||||
const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal});
|
||||
const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);}
|
||||
const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data;
|
||||
}finally{clearTimeout(timer);managementRequests.delete(controller);}
|
||||
}
|
||||
function state(){return {bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};}
|
||||
function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}}
|
||||
function layout(){const tab=activeTab();if(!win||!tab)return;view=tab.view;startPage=tab.startPage;const [width,height]=win.getContentSize(),left=leftSidebar?240:0;view.setBounds({x:left,y:chromeHeight,width:Math.max(0,width-left-((aiPanel||vaultPanel)?360:0)),height:Math.max(0,height-chromeHeight)});view.setVisible(ready&&tab.guarded&&!startPage&&!overviewPanel&&!toolsOpen);}
|
||||
async function pageTheme(tab=activeTab()){
|
||||
if(!tab||!ready)return;const contents=tab.view.webContents,url=contents.getURL(),context=tabContext(tab.id,{foreground:false});if(!/^https?:\/\//.test(url))return;
|
||||
try{const result=await contents.executeJavaScriptInIsolatedWorld(999,[{code:`(()=>{if(location.href!==${JSON.stringify(url)})return null;return {color:(document.querySelector('meta[name="theme-color"]')?.content||'').slice(0,32),background:getComputedStyle(document.body||document.documentElement).backgroundColor};})()`}],false);
|
||||
if(!currentTabContext(context)||!result)return;const hex=/^#[a-f0-9]{6}$/i.test(result.color)?result.color:null,rgb=/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(result.background||'');tab.color=hex||(rgb&&rgb.slice(1).every(v=>+v<=255)?'#'+rgb.slice(1).map(v=>(+v).toString(16).padStart(2,'0')).join(''):'#edf1f6');tab.title=contents.getTitle().slice(0,200);if(tab===activeTab()){pageColor=tab.color;pageTitle=tab.title;}emit();
|
||||
}catch{}
|
||||
}
|
||||
function lockVault(){const unlocked=vault&&!vault.status().locked;vault?.lock();if(unlocked)audit('vault.locked');emit();}
|
||||
function quarantine(reason){ready=false;if(policy&&Date.parse(policy.expiresAt)<=Date.now())configureDiagnostics({enabled:false});for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
|
||||
function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};}
|
||||
function vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};}
|
||||
function requireVault(){
|
||||
requireFeatureSection('passwords');
|
||||
const settings=vaultSettings();if(!settings.enabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('Password manager is disabled or browser access is paused');
|
||||
requireEncryption();
|
||||
if(!vault||vault.status().idleSeconds!==settings.idleLockSeconds){vault?.lock();vault=createVault({filePath:path.join(app.getPath('userData'),'password-vault.json'),idleSeconds:settings.idleLockSeconds,protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});}
|
||||
return vault;
|
||||
}
|
||||
function persistAudit(){requireEncryption();fs.writeFileSync(path.join(app.getPath('userData'),'audit-queue.bin'),safeStorage.encryptString(JSON.stringify(auditQueue)),{mode:0o600});}
|
||||
async function flushAudit(){
|
||||
if(!device||flushing||!auditQueue.length)return;flushing=true;
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){await request(device.server,'/api/device/events',auditQueue[0],device.token);auditQueue.shift();persistAudit();}}
|
||||
catch{}finally{flushing=false;}
|
||||
}
|
||||
async function audit(kind,ruleId,host,fileFacts){
|
||||
if(!device||!policy)return;
|
||||
// Operational correlation contains no rule text, hostname or forensic payload.
|
||||
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':'unknown'}));}catch{}
|
||||
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
|
||||
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();flushAudit();}
|
||||
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
|
||||
}
|
||||
function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));}
|
||||
async function applyPolicyImpl(envelope){
|
||||
const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id);
|
||||
const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0});
|
||||
const nextBookmarkJSON=JSON.stringify(nextBookmarks.items);
|
||||
if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision');
|
||||
const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);};
|
||||
if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin');
|
||||
if(policy&&next.version===policy.version){
|
||||
// Same versions must describe identical policy; reject equivocation.
|
||||
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
|
||||
}
|
||||
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
|
||||
const proxy=next.proxy;
|
||||
const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})};
|
||||
await browserSession.closeAllConnections();await browserSession.setProxy(settings);
|
||||
await managementSession.closeAllConnections();await managementSession.setProxy(settings);
|
||||
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
|
||||
await browserSession.clearHostResolverCache();recentRequests.clear();
|
||||
await managementSession.clearHostResolverCache();
|
||||
policy=next;device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
|
||||
for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}
|
||||
if((!next.agents.enabled||next.browser.settings.hiddenSections.includes('agents')||next.browser.settings.lockedSections.includes('agents'))&&agentService){agentService.revoke();await agentService.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}}
|
||||
await audit('policy.applied');
|
||||
}
|
||||
async function sync(){
|
||||
if(!device||syncing)return;syncing=true;
|
||||
try{
|
||||
const envelope=await request(device.server,'/api/device/policy',null,device.token);await applyPolicy(envelope);
|
||||
await request(device.server,'/api/device/heartbeat',{policyVersion:policy.version},device.token);
|
||||
await flushAudit();if(auditQueue.length>=10000)throw Error('Security event queue is full');
|
||||
// Mandatory local inspection must be attested before unlocking the browser;
|
||||
// first user gestures must not consume their scan budget waiting for ACL setup.
|
||||
if(policy.security.yara.enabled||policy.dlp.semantic.enabled){
|
||||
if(!localInspector)throw Error('Required private content inspector is unavailable');
|
||||
await diagnosticSpan('browser.parser',{operation:'scan'},()=>localInspector.waitReady());
|
||||
}
|
||||
if(Date.parse(policy.expiresAt)<=Date.now())throw Error('Company policy expired');
|
||||
if(clipboardFatal)throw Error('Managed renderer security failed; restart the browser');
|
||||
const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(policy.enterprise?.vpn));
|
||||
if(!posture.allowed)throw Error('Required corporate network is unavailable. Connect your company VPN.');
|
||||
managementHealthy=true;
|
||||
const blocked=stickyBlock();
|
||||
if(blocked&&blocked.policyVersion===policy.version&&(blockedTab()?.security.visualBlocked?policy.security.visualPhishing.enabled:policy.security.content.blockedPhrases.length)){message=blocked.message;ready=false;emit();layout();return;}
|
||||
for(const tab of tabs.all()){tab.security.visualBlocked=null;tab.security.contentBlocked=null;}
|
||||
const recovering=!ready;ready=true;tabs.resume(policy.version);message=`Managed · policy v${policy.version}`;emit();layout();
|
||||
await ensureAgentAPI();
|
||||
if(recovering){for(const tab of tabs.all()){const current=tab.view.webContents.getURL();if(/^https?:\/\//.test(current)&&core.destinationAllowed(current,policy))tab.view.webContents.reload();else if(current!=='about:blank'||tabs.all().length===1)await navigate({url:policy.browser.homeUrl,tabId:tab.id});}emit();}
|
||||
}catch(e){
|
||||
// A deliberate page quarantine aborts in-flight management requests too.
|
||||
// Preserve that stronger, sticky verdict instead of replacing it with an
|
||||
// expected cancellation error or treating it as a management outage.
|
||||
const blocked=stickyBlock();
|
||||
if(e.name==='AbortError'&&!ready&&blocked&&message===blocked.message){emit();return;}
|
||||
managementHealthy=false;quarantine(e.status===401?'Device revoked or credentials rejected. Contact your administrator.':`Browsing paused: ${e.message}`);if(e.status===401){configureDiagnostics({enabled:false});agentService?.revoke();await agentService?.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}await browserSession.closeAllConnections();await browserSession.clearStorageData();}
|
||||
}
|
||||
finally{syncing=false;}
|
||||
}
|
||||
async function completeEnrollment(server,result){
|
||||
if(device)throw Error('This installation is already enrolled');
|
||||
const candidate={server,id:result.id,token:result.token,publicKey:result.publicKey,minimumVersion:0};
|
||||
candidate.channelSignatureKey=require('../../packages/core/policy-channel.cjs').verifyIdentity(result.managementChannel,candidate.publicKey);
|
||||
const channelPin=require('./management-channel.cjs').deploymentPin();if(channelPin&&candidate.channelSignatureKey.trim()!==channelPin)throw Error('Protected management signing pin mismatch');
|
||||
// Optional deployment-time key pin strengthens first enrollment beyond TLS trust.
|
||||
if(process.env.SAFETYNET_POLICY_KEY_FILE && fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==candidate.publicKey.trim())throw Error('Management signing key does not match deployment pin');
|
||||
const initialPolicy=await require('./management-channel.cjs').protectedPolicyRequest({device:candidate,request,save:()=>{}});
|
||||
assertPolicyAssignment(core.verifyPolicy(initialPolicy,candidate.publicKey),candidate.id);
|
||||
saveDevice(candidate);device=candidate;await sync();return state();
|
||||
}
|
||||
function navigate(...args){return diagnosticSpan('browser.navigation',{},()=>navigateImpl(...args));}
|
||||
async function navigateImpl(input,captured,options={}){
|
||||
const address=typeof input==='string'?input:input?.url;
|
||||
if(typeof address!=='string'||address.length>4096)throw Error('Invalid address');
|
||||
const tab=tabs.get(captured?.sessionId||input?.tabId||activeTab().id),contents=tab.view.webContents;
|
||||
if(captured&&(!currentTabContext(captured.token)||captured.webContents!==contents))throw Error('Agent context changed');
|
||||
const url=address.includes('://')?address:`https://${address}`;
|
||||
if(!policy||!['https:','http:'].includes(new URL(url).protocol)||!core.destinationAllowed(url,policy))throw Error('Destination is blocked by company policy');
|
||||
const blocked=stickyBlock(),documentURL=value=>{const parsed=new URL(value);parsed.hash='';return parsed.href;};
|
||||
if(blocked&&managementHealthy&&Date.parse(policy.expiresAt)>Date.now()&&blockedTab()===tab&&documentURL(url)!==documentURL(blocked.checkedURL)){
|
||||
for(const record of tabs.all()){record.security.visualBlocked=null;record.security.contentBlocked=null;}ready=true;tabs.resume(policy.version);message='Checking destination…';
|
||||
}
|
||||
if(!ready)throw Error('Browser is not managed and ready');
|
||||
if(options.signal?.aborted)throw Error('Agent action cancelled');
|
||||
const initial=policy;tab.startPage=false;if(tab===activeTab())startPage=false;layout();
|
||||
const abort=()=>contents.stop();options.signal?.addEventListener('abort',abort,{once:true});
|
||||
try{await contents.loadURL(url);}finally{options.signal?.removeEventListener('abort',abort);}
|
||||
if(options.signal?.aborted||!ready||policy!==initial||contents.isDestroyed()||tabs.fromContents(contents.id)!==tab)throw Error('Navigation context changed');
|
||||
const refreshed=captured?agentContext(tab.id):null;
|
||||
if(captured?.token.foreground&&(!refreshed.foreground||captured.token.activation!==refreshed.token.activation))throw Error('Agent foreground changed during navigation');
|
||||
emit();return refreshed||state();
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:core.destinationAllowed};}
|
||||
function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));}
|
||||
async function managedAIImpl(prompt){
|
||||
requireFeatureSection('ai');
|
||||
if(!ready||!policy?.ai.enabled)throw Error('AI access is disabled by company policy');
|
||||
if(typeof prompt!=='string'||!prompt.trim()||prompt.length>100000)throw Error('Enter a prompt of up to 100,000 characters');
|
||||
const initial=policy,verdict=core.inspectForAI(Buffer.from(prompt),initial);
|
||||
if(verdict.decision==='block'){await audit('dlp.block',verdict.ruleId);throw Error(`Blocked by company policy: ${verdict.ruleId}`);}
|
||||
if(initial.dlp.semantic.enabled||initial.security.yara.enabled){if(!localInspector)throw Error('Local inspector unavailable');const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(prompt)},()=>localInspector.inspect(Buffer.from(prompt),initial));if(result.decision!=='allow'&&!core.allowsConfidentialLocal(result,initial))throw Error('Local content policy blocked this prompt');}
|
||||
const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(prompt)},()=>scanEnterprise(Buffer.from(prompt),initial,'ai'));if(external.decision!=='allow'){await audit('dlp.block',external.ruleId);throw Error('Company external scanner blocked this prompt');}
|
||||
if(!ready||policy.version!==initial.version)throw Error('Policy changed during inspection');
|
||||
const result=await request(device.server,'/api/device/ai',{prompt},device.token);
|
||||
if(!ready||policy.version!==initial.version)throw Error('Policy changed during AI request');return result;
|
||||
}
|
||||
async function mutateManagedBookmarks(input){
|
||||
if(bookmarkBusy||syncing)throw Error('Management update in progress. Try again.');
|
||||
const initial=policy,identity=device;
|
||||
const payload=validateBookmarkMutation(input,{policy:effectiveBookmarkPolicy(),ready,settings:managedSettings(),destinationAllowed:core.destinationAllowed});
|
||||
bookmarkBusy=true;
|
||||
try{
|
||||
const result=await request(identity.server,'/api/device/bookmarks',payload,identity.token);
|
||||
if(policy!==initial||device!==identity)throw Error('Managed policy changed during bookmark update');
|
||||
const deadline=Date.now()+12000;while(syncing){if(Date.now()>=deadline)throw Error('Bookmark update awaits management synchronization');await new Promise(resolve=>setTimeout(resolve,50));}
|
||||
await sync();
|
||||
if(!ready||device!==identity||personalBookmarks.revision!==result.revision||policy.version!==result.version)throw Error('Bookmark update requires a fresh signed policy');
|
||||
return state();
|
||||
}finally{bookmarkBusy=false;}
|
||||
}
|
||||
async function ensureAgentAPI(){
|
||||
if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true;
|
||||
try{
|
||||
const getContext=agentContext;
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,ai:managedAI,
|
||||
inspect:async(text,context)=>{
|
||||
const aiContext=context.action==='ai';
|
||||
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
|
||||
if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
|
||||
if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
|
||||
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
|
||||
}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
|
||||
},audit:event=>audit('agent.denied',`${event.action}:${event.outcome}`)});
|
||||
for(const action of ['click','type']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
|
||||
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(...args)=>diagnosticSpan('browser.agent',{operation:action},()=>original(...args));}
|
||||
agentService=await startAgentControl({actions,
|
||||
onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);},
|
||||
authorize:({action,args,scope})=>{
|
||||
if(!ready||!policy?.agents.enabled||Date.parse(policy.expiresAt)<=Date.now()||!policy.agents.scopes.includes(action))return {allowed:false};
|
||||
if(action==='sessions'||Object.hasOwn(agentAppControls(),action))return {allowed:true,role:'agent',scopes:[scope]};
|
||||
let tab;try{tab=args.sessionId?tabs.get(args.sessionId):activeTab();}catch{return {allowed:false};}
|
||||
const url=action==='navigate'?args.url:tab.view.webContents.getURL();
|
||||
if(!core.destinationAllowed(url,policy))return {allowed:false};
|
||||
if(policy.agents.allowedHosts.length&&!policy.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)))return {allowed:false};
|
||||
return {allowed:true,role:'agent',scopes:[scope]};
|
||||
},audit:event=>audit(event.outcome==='denied'?'agent.denied':'agent.action',`${event.action}:${event.outcome}`),
|
||||
persistCredential:async connection=>{const protectedConnection=await require('./dpapi.cjs').capabilityCrypto('protect',JSON.stringify(connection));fs.writeFileSync(agentFile(),JSON.stringify({format:'windows-dpapi-v1',protectedConnection}),{mode:0o600});}
|
||||
});emit();
|
||||
}finally{agentStarting=false;}
|
||||
}
|
||||
function dnsGate(...args){return diagnosticSpan('browser.dns',{},()=>dnsGateImpl(...args));}
|
||||
async function dnsGateImpl(url,currentPolicy){
|
||||
const settings=currentPolicy.security.dns;
|
||||
if(!settings.enabled||!settings.blockedIPs.length)return;
|
||||
const host=new URL(url).hostname;
|
||||
if(securityCore.dnsAddressBlocked(host.replace(/^\[|\]$/g,''),currentPolicy))throw Error('dns-ip-blocklist');
|
||||
let timer;const resolved=await Promise.race([browserSession.resolveHost(host,{cacheUsage:'allowed'}),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('dns-resolution-timeout')),5000);})]).finally(()=>clearTimeout(timer));
|
||||
const addresses=resolved.endpoints?.map(v=>v.address)||[];
|
||||
if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist');
|
||||
}
|
||||
function hooks(){
|
||||
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
|
||||
browserSession.setPermissionCheckHandler(()=>false);
|
||||
const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy,
|
||||
fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector)),
|
||||
choosePath:async name=>{const chosen=await dialog.showSaveDialog(win,{title:'Save scanned download (choose a new file)',defaultPath:path.join(app.getPath('downloads'),name)});return chosen.canceled?null:chosen.filePath;},
|
||||
writeFile:async(file,bytes,current)=>{
|
||||
if(!current()||!path.isAbsolute(file)||file.includes('\\\\?\\')||file.slice(2).includes(':'))throw Error('Save path rejected');
|
||||
const handle=await fs.promises.open(file,'wx',0o600);let release=false;
|
||||
try{if(!current())throw Error('Policy changed');await handle.writeFile(bytes);await handle.sync();if(!current())throw Error('Policy changed');release=true;}
|
||||
finally{await handle.close();if(!release)await fs.promises.unlink(file).catch(()=>{});}
|
||||
},audit,status:text=>{message=text;emit();}});
|
||||
browserSession.on('will-download',(event,item,contents)=>{
|
||||
const url=item.getURL(),name=item.getFilename(),tracked=recentRequests.get(`${contents?.id}:${url}`);event.preventDefault();
|
||||
if(!/^https?:\/\//.test(url)){audit('download.block','unsupported-download');return;}
|
||||
diagnosticSpan('browser.download',{},()=>download({url,name,method:tracked?.method,webContentsId:contents?.id})).catch(()=>audit('download.block','download-inspection-failed'));
|
||||
});
|
||||
browserSession.webRequest.onBeforeRequest({urls:['<all_urls>']},(details,callback)=>{
|
||||
// A delegated native frontend may load its bundled Chromium UI only. Its HTTP
|
||||
// requests receive no exemption, and neither pages nor trusted chrome qualify.
|
||||
if(developerAssetRequestAllowed(details,{ready,policy,tabs:tabs.all()})){callback({cancel:false});return;}
|
||||
let called=false;const finish=cancel=>{if(!called){called=true;callback({cancel});}};
|
||||
const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null;
|
||||
const sourceCurrent=()=>!sourceContext||currentTabContext(sourceContext);const timer=setTimeout(()=>finish(true),40000);
|
||||
// Async gates complete before Chromium may transmit the request.
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||(details.webContentsId>0&&(!source||!source.guarded))){finish(true);return;}
|
||||
if(!core.destinationAllowed(details.url,policy)){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
|
||||
recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time<Date.now()-120000||recentRequests.size>2000)recentRequests.delete(url);
|
||||
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;}
|
||||
if(!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
|
||||
if(details.resourceType==='webSocket'&&!policy.browser.allowWebSockets){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
|
||||
const address=new URL(details.url),query=address.pathname+address.search;
|
||||
const queryVerdict=core.inspect(Buffer.from(query),policy);
|
||||
if(queryVerdict.decision==='block'){audit('dlp.block',queryVerdict.ruleId,new URL(details.url).hostname);finish(true);return;}
|
||||
const uploads=details.uploadData||[];
|
||||
if(policy.security.visualPhishing.enabled&&(uploads.length||(address.search&&details.resourceType==='xhr')||details.resourceType==='webSocket')){
|
||||
const checked=source?await checkVisual(source):{decision:'unknown'};if(checked.decision!=='allow'||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
|
||||
}
|
||||
// Electron file/data-pipe interception cannot bind scanning to the bytes later sent.
|
||||
// Block these paths rather than scanning a mutable file and allowing a race.
|
||||
if(uploads.some(part=>!part.bytes || part.file || part.blobUUID)){audit('dlp.block','uninspectable-upload',new URL(details.url).hostname);finish(true);return;}
|
||||
if(!['GET','HEAD','OPTIONS'].includes(details.method)&&!uploads.length){audit('dlp.block','missing-request-body',new URL(details.url).hostname);finish(true);return;}
|
||||
if(uploads.length){
|
||||
if(uploads.reduce((n,p)=>n+p.bytes.length,0)>policy.dlp.maxBytes){audit('dlp.block','size-limit',new URL(details.url).hostname);finish(true);return;}
|
||||
const requestBytes=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes)));
|
||||
const transfer=require('../../packages/core/file-transfer.cjs').inspectFileTransfer(requestBytes,policy,'upload');
|
||||
const fileFacts={bytes:requestBytes.length,detectedType:transfer.detectedType,sha256:transfer.sha256};
|
||||
const releaseUpload=async()=>{await audit('upload.authorized',null,new URL(details.url).hostname,fileFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());};
|
||||
if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,fileFacts);requestBytes.fill(0);finish(true);return;}
|
||||
const verdict=core.inspect(requestBytes,policy);requestBytes.fill(0);
|
||||
if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);finish(true);return;}
|
||||
if(verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled||icapRequired(policy,'upload')){
|
||||
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
|
||||
if(needsLocal&&!localInspector){audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
|
||||
const snapshot=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;
|
||||
try{let result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload'));
|
||||
const cancel=!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow';
|
||||
if(cancel){audit('dlp.block',result.ruleId||'policy-changed',new URL(details.url).hostname);finish(true);}else await releaseUpload();
|
||||
}catch{audit('dlp.block','inspection-incomplete',new URL(details.url).hostname);finish(true);}finally{snapshot.fill(0);}return;
|
||||
}else{await releaseUpload();return;}
|
||||
}
|
||||
finish(false);
|
||||
}catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true));
|
||||
});
|
||||
// Session request and permission gates are installed exactly once.
|
||||
}
|
||||
function installSiteHooks(tab){
|
||||
const contents=tab.view.webContents,security=tab.security;
|
||||
const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}});
|
||||
security.developerToolsGuard=developerTools;
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy))event.preventDefault();});
|
||||
contents.on('will-attach-webview',event=>event.preventDefault());
|
||||
contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit());
|
||||
contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();});
|
||||
installClipboardControls(contents);
|
||||
security.visualScanner=createVisualPhishing({getContext:()=>({ready,policy,webContents:contents,url:contents.getURL(),navigationId:tab.navigationId}),
|
||||
describe:url=>visualDeadline(contents.executeJavaScriptInIsolatedWorld(999,[{code:describeScript(url)}],false)),
|
||||
capture:async(scope,description)=>{let rect;if(scope==='login-form'){const r=description.formRect;if(!r)throw Error('Login region unavailable');rect={x:Math.floor(r.x),y:Math.floor(r.y),width:Math.ceil(r.width),height:Math.ceil(r.height)};}const bytes=await visualDeadline(capturePNG(contents,rect));if(bytes.length<24)throw Error('Capture dimensions unavailable');return {bytes,width:bytes.readUInt32BE(16),height:bytes.readUInt32BE(20)};},
|
||||
fingerprint:capture=>{try{return fingerprintPNG(capture.bytes);}finally{capture.bytes.fill(0);}},compare:hammingDistance,
|
||||
audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);}
|
||||
});
|
||||
}
|
||||
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
||||
async function createNativeTabImpl(input={}){
|
||||
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
||||
if(input.group)requireManagedSection('library');
|
||||
const tab=tabs.create({group:input.group||''});installSiteHooks(tab);
|
||||
try{
|
||||
await tab.view.webContents.loadURL('about:blank');
|
||||
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
||||
tab.guarded=true;selectTab(tab.id);
|
||||
if(input.url)await navigate({url:input.url,tabId:tab.id});emit();return state();
|
||||
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
||||
}
|
||||
|
||||
function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();}
|
||||
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
function agentAppControls(){
|
||||
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
|
||||
if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed');
|
||||
return operation(args);
|
||||
};
|
||||
return {
|
||||
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
|
||||
'settings:get':checked(()=>managedSettings()),
|
||||
'settings:update':checked(args=>updateManagedAppearance(args.appearance)),
|
||||
'bookmarks:list':checked(()=>({...bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),policyVersion:policy.version})),
|
||||
'bookmarks:mutate':checked(async args=>{await mutateManagedBookmarks(args);return {completed:true};}),
|
||||
'tabs:create':checked(async args=>{const result=await createNativeTab(args);return {completed:true,sessionId:result.activeTabId};}),
|
||||
'tabs:activate':checked(args=>{selectTab(args.sessionId);emit();return {completed:true,sessionId:args.sessionId};}),
|
||||
'tabs:close':checked(async args=>{await closeNativeTab(args.sessionId);return {completed:true};}),
|
||||
'tabs:group':checked(args=>{groupNativeTab({id:args.sessionId,group:args.group});return {completed:true,sessionId:args.sessionId};})
|
||||
};
|
||||
}
|
||||
function ipc(channel,fn){ipcMain.handle(channel,async(event,...args)=>{
|
||||
if(event.sender!==win.webContents || event.senderFrame!==win.webContents.mainFrame || event.senderFrame?.url!==uiUrl)throw Error('Untrusted IPC sender');
|
||||
try{return {ok:true,data:await diagnosticSpan('browser.ipc',{operation:channel.slice('managed:'.length)},()=>fn(...args))};}catch(e){return {ok:false,error:e.message};}
|
||||
});}
|
||||
app.whenReady().then(async()=>{
|
||||
if(!ownsBrowserInstance)return;
|
||||
if(cliRequest){try{await require('./agent-client.cjs').runClient(cliConnection,cliRequest);}catch{console.error('Local agent action failed. Check policy and encrypted pairing file.');app.exit(1);}return;}
|
||||
core=await import('../../packages/core/policy.mjs');
|
||||
clipboardCore=await import('../../packages/core/clipboard.mjs');
|
||||
ipcMain.on('managed:clipboard-sandbox-failure',event=>{if(tabs?.fromContents(event.sender.id)||event.sender===win?.webContents){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');}});
|
||||
powerMonitor.on('lock-screen',lockVault);powerMonitor.on('suspend',lockVault);
|
||||
securityCore=await import('../../packages/core/security.mjs');
|
||||
localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged);
|
||||
browserSession=session.fromPartition('persist:safetynet-managed',{cache:false});
|
||||
// Management has its own cookie jar and no website request gate. It uses the
|
||||
// same company proxy and Chromium DNS configuration as managed website traffic.
|
||||
managementSession=session.fromPartition('persist:safetynet-management',{cache:false});
|
||||
browserSession.registerPreloadScript({type:'frame',id:'managed-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
|
||||
session.defaultSession.registerPreloadScript({type:'frame',id:'shell-clipboard-deny',filePath:path.join(__dirname,'clipboard-deny.cjs')});
|
||||
Menu.setApplicationMenu(null);
|
||||
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
||||
win.removeMenu();
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)});
|
||||
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
||||
clipboardGuard=createClipboardGuard({
|
||||
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
||||
stage:async args=>{const tab=tabs.fromContents(args.webContents.id);if(tab&&args.operation==='paste'&&policy?.security.visualPhishing.enabled&&(await checkVisual(tab)).decision!=='allow')throw Error('Login protection holds paste');return executeClipboard('stage',args);},
|
||||
validate:args=>executeClipboard('validate',args),commit:args=>executeClipboard('commit',args),
|
||||
discard:args=>executeClipboard('discard',{...args,url:'',operation:'copy',limit:0}),
|
||||
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
|
||||
writeClipboard:text=>clipboard.writeText(text),
|
||||
inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;},
|
||||
audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}}
|
||||
});
|
||||
installClipboardControls(win.webContents);hooks();
|
||||
ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};});
|
||||
ipcMain.handle('managed:visual-check',async(event,input)=>{
|
||||
const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!input||typeof input.token!=='string'||input.token.length>80||!Number.isSafeInteger(input.revision)||input.revision<0||input.url!==event.sender.getURL())return {decision:'unknown'};
|
||||
const security=tab.security;if(input.token!==security.visualToken||input.revision!==security.visualRevision){tabs.navigation(tab.id);security.visualToken=input.token;security.visualRevision=input.revision;}
|
||||
try{return await checkVisual(tab);}catch{return {decision:'unknown'};}
|
||||
});
|
||||
await createNativeTab();
|
||||
win.webContents.setWindowOpenHandler(()=>({action:'deny'}));win.webContents.on('will-navigate',event=>event.preventDefault());
|
||||
ipc('managed:state',()=>({...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()}));
|
||||
ipc('managed:window-control',action=>{if(action==='minimize')win.minimize();else if(action==='maximize'){win.isMaximized()?win.unmaximize():win.maximize();}else if(action==='close')win.close();else throw Error('Unknown window action');});
|
||||
ipc('managed:authorize-setting',section=>{requireManagedSection(section);return {authorized:true,policyVersion:policy.version};});
|
||||
ipc('managed:bookmarks-mutate',mutateManagedBookmarks);
|
||||
ipc('managed:settings-update',updateManagedAppearance);
|
||||
ipc('managed:chrome-layout',async input=>{
|
||||
if(!input||![64,104].includes(input.height)||['sidebarOpen','startPageOpen','overviewOpen'].some(key=>typeof input[key]!=='boolean'))throw Error('Unsupported browser layout');
|
||||
if(managedSettings().available&&!sectionAllowed(managedSettings(),'appearance')&&input.height!==(managedSettings().appearance.tabMode==='compact'?64:104))throw Error('Tab layout is controlled by your administrator');
|
||||
if(input.toolsOpen&&!managedSettings().visible)throw Error('Settings access is disabled by your administrator');
|
||||
if(input.sidebarOpen&&!libraryVisible(managedSettings()))throw Error('Library access is controlled by your administrator');
|
||||
if(input.toolsOpen!==undefined&&typeof input.toolsOpen!=='boolean')throw Error('Unsupported tools layout');chromeHeight=input.height;leftSidebar=input.sidebarOpen;startPage=activeTab()?.startPage??true;overviewPanel=input.overviewOpen;toolsOpen=!!input.toolsOpen;layout();
|
||||
if(policy?.security.visualPhishing.enabled)await configureVisualPage(true,activeTab());return {height:chromeHeight};
|
||||
});
|
||||
ipc('managed:share-url',async()=>{
|
||||
if(!ready)throw Error('Browser access paused');const initial=policy,context=tabContext(),contents=context.webContents,pageURL=contents.getURL(),url=new URL(pageURL);
|
||||
if(!['https:','http:'].includes(url.protocol)||url.username||url.password)throw Error('Cannot share this page');url.search='';url.hash='';const text=url.toString(),bytes=Buffer.from(text);
|
||||
let verdict=clipboardCore.inspectClipboard(bytes,initial,'copy');
|
||||
if(verdict.decision==='allow'&&(initial.dlp.semantic.enabled||initial.security.yara.enabled))verdict=localInspector?await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,initial)):{decision:'block',ruleId:'clipboard-inspector-unavailable'};
|
||||
if(verdict.decision==='allow')verdict=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,initial,'clipboard'));
|
||||
if(verdict.decision!=='allow'){await audit('clipboard.block',verdict.ruleId);throw Error('Company policy blocks sharing this link');}
|
||||
await audit('clipboard.copy','authorized-link');
|
||||
if(!ready||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||!currentTabContext(context))throw Error('Browser state changed during link inspection');
|
||||
await clipboard.writeText(text);return {copied:true};
|
||||
});
|
||||
ipc('managed:enroll',async input=>{
|
||||
if(device)throw Error('Already enrolled');requireEncryption();
|
||||
if(!input||typeof input.server!=='string'||typeof input.code!=='string'||input.code.length>200)throw Error('Invalid enrollment');
|
||||
const server=managementUrl(input.server),result=await request(server,'/api/device/enroll',{code:input.code,name:os.hostname(),platform:process.platform});
|
||||
return completeEnrollment(server,result);
|
||||
});
|
||||
ipc('managed:sso',async input=>{
|
||||
if(device)throw Error('Already enrolled');requireEncryption();const server=managementUrl(input.server);
|
||||
const ticket=await request(server,'/api/device/sso/start',{});
|
||||
const loginUrl=new URL(ticket.url);if(loginUrl.origin!==server || loginUrl.pathname!=='/auth/login')throw Error('Invalid login URL');
|
||||
await shell.openExternal(loginUrl.toString());
|
||||
message='Complete enterprise sign-in in your system browser…';emit();
|
||||
for(let attempt=0;attempt<100;attempt++){
|
||||
await new Promise(resolve=>setTimeout(resolve,3000));
|
||||
const result=await request(server,'/api/device/sso/poll',{id:ticket.id,secret:ticket.secret,name:os.hostname(),platform:process.platform});
|
||||
if(!result.pending)return completeEnrollment(server,result);
|
||||
}
|
||||
throw Error('Enterprise sign-in expired');
|
||||
});
|
||||
ipc('managed:tab-create',createNativeTab);
|
||||
ipc('managed:tab-activate',id=>{selectTab(id);emit();return state();});
|
||||
ipc('managed:tab-close',closeNativeTab);
|
||||
ipc('managed:tab-group',groupNativeTab);
|
||||
ipc('managed:navigate',navigate);
|
||||
ipc('managed:back',()=>{if(ready&&view.webContents.navigationHistory.canGoBack())view.webContents.navigationHistory.goBack();});
|
||||
ipc('managed:forward',()=>{if(ready&&view.webContents.navigationHistory.canGoForward())view.webContents.navigationHistory.goForward();});
|
||||
ipc('managed:reload',()=>{if(ready)view.webContents.reload();});
|
||||
ipc('managed:ai-panel',open=>{if(open)requireFeatureSection('ai');aiPanel=!!open;if(aiPanel){vaultPanel=false;lockVault();}layout();});
|
||||
ipc('managed:vault-panel',open=>{if(open)requireVault();vaultPanel=!!open;if(vaultPanel)aiPanel=false;else lockVault();layout();});
|
||||
ipc('managed:vault-status',()=>{requireFeatureSection('passwords');return vaultStatus();});
|
||||
ipc('managed:vault-create',async master=>{requireVault().create(master);await audit('vault.created');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();});
|
||||
ipc('managed:vault-unlock',async master=>{requireVault().unlock(master);await audit('vault.unlocked');if(!ready){lockVault();throw Error('Browser access paused');}emit();return vaultStatus();});
|
||||
ipc('managed:vault-lock',()=>{lockVault();return vaultStatus();});
|
||||
ipc('managed:vault-list',()=>requireVault().list());
|
||||
ipc('managed:vault-save',async entry=>{if(!vaultSettings().allowSaving)throw Error('Saving passwords is disabled by company policy');const result=requireVault().upsert(entry);await audit('vault.saved');emit();return result;});
|
||||
ipc('managed:vault-delete',async id=>{if(!vaultSettings().allowSaving)throw Error('Password changes are disabled by company policy');const result=requireVault().delete(id);await audit('vault.deleted');return result;});
|
||||
ipc('managed:vault-generate',()=>{const current=requireVault();if(!vaultSettings().allowSaving||current.status().locked)throw Error('Unlock the vault before generating a password');return current.generatePassword();});
|
||||
ipc('managed:vault-fill',async id=>{
|
||||
const context=tabContext(),tab=tabs.get(context.tabId),contents=context.webContents;
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('Show the saved login website before filling credentials');
|
||||
if((await checkVisual(tab)).decision!=='allow'||!currentTabContext(context))throw Error('Visual login protection has not approved this page');
|
||||
if(startPage||overviewPanel||toolsOpen)throw Error('The login website is no longer visible');
|
||||
const current=requireVault(),initial=policy,url=context.url;
|
||||
if(!initial.passwordManager.allowAutofill||!core.destinationAllowed(url,initial)||new URL(url).protocol!=='https:')throw Error('Password filling requires an approved HTTPS destination');
|
||||
let credential;
|
||||
try{
|
||||
const origin=new URL(url).origin;credential={...current.credential(id,origin),origin};
|
||||
await audit('vault.fill-requested');
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||Date.parse(initial.expiresAt)<=Date.now()||current.status().locked)throw Error('Browser state changed');
|
||||
contents.focus();if(!currentTabContext(context))throw Error('Browser state changed');
|
||||
const result=await diagnosticSpan('browser.password.fill',{sessionId:context.tabId},()=>contents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(initial.expiresAt))}],false));
|
||||
if(!currentTabContext(context)||startPage||overviewPanel||toolsOpen||policy!==initial||current.status().locked||result?.completed!==true)throw Error('Browser state changed');
|
||||
await audit('vault.filled');return {completed:true};
|
||||
}catch{throw Error('Cannot fill this login. Unlock the vault and use its exact HTTPS origin with a single visible same-origin POST login form.');}
|
||||
finally{if(credential){credential.username='';credential.password='';}}
|
||||
});
|
||||
ipc('managed:ai',managedAI);
|
||||
ipc('managed:agent-connection',()=>{
|
||||
requireFeatureSection('agents');
|
||||
if(!ready||!policy.agents.enabled||!agentService)throw Error('Agent access is disabled by company policy');return {address:agentService.address,credentialPath:agentFile()};
|
||||
});
|
||||
ipcMain.on('managed:page-content',async(event,input)=>{
|
||||
const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!ready||typeof input?.text!=='string'||input.text.length>250000)return;
|
||||
const initial=policy,context=tabContext(tab.id,{foreground:false});if(!initial.security.content.blockedPhrases.length)return;
|
||||
const result=core.inspect(Buffer.from(input.text),initial);if(result.decision==='block'&&result.ruleId?.startsWith('content-')&¤tTabContext(context)&&policy===initial){await audit('content.block',result.ruleId);if(!currentTabContext(context))return;tab.security.contentBlocked={checkedURL:context.url,policyVersion:initial.version,message:'This page contains content blocked by company policy.'};quarantine(tab.security.contentBlocked.message);}
|
||||
});
|
||||
ipc('managed:inspect-file',async()=>{
|
||||
requireFeatureSection('documentScan');
|
||||
if(!ready||!policy)throw Error('Device must be managed before inspecting files');
|
||||
if(policy.inspection?.documentAnalysisEnabled===false)throw Error('Document analysis is disabled for this browser by your administrator');
|
||||
if(!localInspector)throw Error('Local content inspector is not installed');
|
||||
const currentPolicy=policy;
|
||||
const selection=await dialog.showOpenDialog(win,{title:icapRequired(policy,'upload')?'Inspect content with company security services':'Inspect company content locally',properties:['openFile']});if(selection.canceled)return null;
|
||||
requireFeatureSection('documentScan');
|
||||
if(!ready||policy!==currentPolicy)throw Error('Policy changed during file selection; inspect again');
|
||||
const file=selection.filePaths[0],handle=await fs.promises.open(file,'r');let bytes;
|
||||
try{const info=await handle.stat();if(!info.isFile()||info.size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit');
|
||||
// Read a bounded immutable snapshot, never release the subsequently mutable original.
|
||||
const chunks=[];let size=0;for await(const chunk of handle.createReadStream()){size+=chunk.length;if(size>policy.dlp.maxBytes)throw Error('File exceeds company inspection limit');chunks.push(chunk);}bytes=Buffer.concat(chunks);
|
||||
}finally{await handle.close();}
|
||||
if(!ready||policy!==currentPolicy){bytes.fill(0);throw Error('Policy changed during file selection; inspect again');}
|
||||
let result;
|
||||
try{result=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));if(result.decision==='allow'&&icapRequired(currentPolicy,'upload')){const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'upload'));if(external.decision!=='allow')result=external;}}finally{bytes.fill(0);}
|
||||
if(!ready||currentPolicy.version!==policy.version)throw Error('Policy changed during inspection; inspect again');
|
||||
if(result.decision==='block')await audit('dlp.block',result.ruleId);
|
||||
return {name:path.basename(file),...result};
|
||||
});
|
||||
win.on('close',()=>tabs.close());
|
||||
win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();});
|
||||
loadAppearance();
|
||||
await win.loadFile(path.join(__dirname,'index.html'));layout();
|
||||
try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath))auditQueue=JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath)));if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);}
|
||||
const refreshMs=!app.isPackaged&&process.env.SAFETYNET_SMOKE_USERDATA===app.getPath('userData')?1000:15000;
|
||||
syncTimer=setInterval(()=>{if(policy&&Date.parse(policy.expiresAt)<=Date.now())quarantine('Company policy expired');sync();},refreshMs);
|
||||
networkTimer=setInterval(async()=>{
|
||||
if(!policy?.enterprise?.vpn.required||networkChecking)return;networkChecking=true;
|
||||
const checkedPolicy=policy;
|
||||
try{const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(checkedPolicy.enterprise.vpn));if(policy===checkedPolicy&&!posture.allowed){managementHealthy=false;quarantine('Required corporate network is unavailable. Connect your company VPN.');await Promise.all([browserSession.closeAllConnections(),managementSession.closeAllConnections()]);}}
|
||||
finally{networkChecking=false;}
|
||||
},5000);
|
||||
let wasLocked=true;vaultTimer=setInterval(()=>{const locked=vault?.status().locked??true;if(locked!==wasLocked){if(locked)audit('vault.locked');wasLocked=locked;emit();}},1000);
|
||||
}).catch(error=>{console.error(error.stack);app.quit();});
|
||||
app.on('window-all-closed',()=>app.quit());
|
||||
app.on('before-quit',()=>{tabs?.close();});
|
||||
app.on('will-quit',()=>{Promise.resolve(diagnostics.shutdown()).catch(()=>{});vault?.lock();clearInterval(vaultTimer);clearInterval(networkTimer);localInspector?.stop();agentService?.close();});
|
||||
app.on('certificate-error',(event,_wc,_url,_error,_certificate,callback)=>{event.preventDefault();callback(false);});
|
||||
app.on('login',(event,_wc,_details,authInfo,callback)=>{
|
||||
event.preventDefault();
|
||||
// Proxy credentials are supplied by deployment environment, never a page or policy bundle.
|
||||
if(authInfo.isProxy&&process.env.SAFETYNET_PROXY_USER&&process.env.SAFETYNET_PROXY_PASSWORD)callback(process.env.SAFETYNET_PROXY_USER,process.env.SAFETYNET_PROXY_PASSWORD);
|
||||
else callback();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const SECTIONS=Object.freeze(['appearance','library','ai','passwords','documentScan','agents']);
|
||||
const DEFAULT_APPEARANCE=Object.freeze({mode:'system',accent:'blue',tabMode:'separate'});
|
||||
// This object is computed in the privileged process, never from renderer storage.
|
||||
function settingsState({policy,ready,preferences={},now=Date.now()}){
|
||||
const available=!!ready&&!!policy&&Date.parse(policy.expiresAt)>now;
|
||||
const rules=policy?.browser?.settings||{};
|
||||
const visible=available&&rules.visible!==false;
|
||||
const hiddenSections=Array.isArray(rules.hiddenSections)?rules.hiddenSections:[];
|
||||
const lockedSections=Array.isArray(rules.lockedSections)?rules.lockedSections:[];
|
||||
const forced=!visible||hiddenSections.includes('appearance')||lockedSections.includes('appearance');
|
||||
return {available,visible,hiddenSections:[...hiddenSections],lockedSections:[...lockedSections],appearance:{...DEFAULT_APPEARANCE,...rules.appearance,...(forced?{}:preferences)}};
|
||||
}
|
||||
function sectionVisible(state,section){return SECTIONS.includes(section)&&state.available&&state.visible&&!state.hiddenSections.includes(section);}
|
||||
function sectionAllowed(state,section){return sectionVisible(state,section)&&!state.lockedSections.includes(section);}
|
||||
function featureAllowed(state,section){return SECTIONS.includes(section)&&state.available&&!state.hiddenSections.includes(section)&&!state.lockedSections.includes(section);}
|
||||
function requireFeature(state,section){if(!featureAllowed(state,section))throw Error('This feature is controlled by your administrator');}
|
||||
function requireSection(state,section){if(!sectionAllowed(state,section))throw Error('This setting is controlled by your administrator');}
|
||||
function validateAppearance(input){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(key=>!Object.hasOwn(DEFAULT_APPEARANCE,key))||!Object.keys(input).length)throw Error('Invalid appearance settings');
|
||||
const values={mode:['system','light','dark'],accent:['blue','teal','violet'],tabMode:['separate','compact']};
|
||||
for(const [key,value] of Object.entries(input))if(!values[key].includes(value))throw Error('Invalid appearance settings');
|
||||
return {...input};
|
||||
}
|
||||
module.exports={settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
const fs=require('node:fs');
|
||||
function deploymentPin(){if(process.env.SAFETYNET_REQUIRE_CHANNEL_PIN==='true'&&!process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE)throw Error('Deployment requires a protected management signing pin');return process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE?fs.readFileSync(process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE,'utf8').trim():undefined;}
|
||||
async function protectedPolicyRequest({device,request,save}){
|
||||
const pin=deploymentPin();if(pin&&device.channelSignatureKey&&device.channelSignatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
if(!device.channelSignatureKey){
|
||||
// One-time legacy migration is authenticated by the enrollment signing pin and HTTPS.
|
||||
const identity=await request(device.server,'/api/device/channel/identity',null,device.token);
|
||||
channel.verifyIdentity(identity,device.publicKey);if(pin&&identity.signatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
device.channelSignatureKey=identity.signatureKey;save(device);
|
||||
}
|
||||
const challenge=await request(device.server,'/api/device/channel/challenge',{},device.token);
|
||||
channel.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
|
||||
const context=channel.prepare(challenge);
|
||||
try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);}
|
||||
finally{context.responseKey.fill(0);}
|
||||
}
|
||||
module.exports={protectedPolicyRequest,deploymentPin};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
const {execFile}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
const net=require('node:net');
|
||||
// No administrator input is interpolated into PowerShell. Adapter matching happens
|
||||
// after bounded JSON parsing; the deployment cannot turn names into shell code.
|
||||
const postureScript="$ErrorActionPreference='Stop'; $dnsRecords=@(Get-DnsClientServerAddress -ErrorAction Stop); $adapters=@(Get-NetAdapter -IncludeHidden | Where-Object {$_.Status -eq 'Up'} | ForEach-Object { $adapter=$_; $dns=@($dnsRecords | Where-Object {$_.InterfaceIndex -eq $adapter.ifIndex} | ForEach-Object {$_.ServerAddresses}); [pscustomobject]@{name=$adapter.Name; dns=$dns} }); ConvertTo-Json -InputObject $adapters -Depth 4 -Compress";
|
||||
function resolverOptions(dns={mode:'system',templates:[]}){
|
||||
if(dns.mode==='system')return {enableBuiltInResolver:false,secureDnsMode:'off',secureDnsServers:[]};
|
||||
if(dns.mode!=='secure'||!Array.isArray(dns.templates)||!dns.templates.length||dns.templates.length>8)throw Error('Managed DNS configuration is invalid');
|
||||
for(const template of dns.templates){const u=new URL(template.replace('{?dns}',''));if(u.protocol!=='https:'||u.username||u.password||u.hash)throw Error('Managed DNS requires HTTPS templates');}
|
||||
return {enableBuiltInResolver:true,secureDnsMode:'secure',secureDnsServers:[...dns.templates]};
|
||||
}
|
||||
function evaluatePosture(vpn,adapters){
|
||||
if(!vpn?.required)return {allowed:true,reason:'not-required'};
|
||||
if(!Array.isArray(vpn.interfaceNames)||!vpn.interfaceNames.length||!Array.isArray(vpn.requiredDnsServers)||vpn.requiredDnsServers.some(ip=>!net.isIP(ip)))return {allowed:false,reason:'configuration-invalid'};
|
||||
if(!Array.isArray(adapters)||adapters.length>256)return {allowed:false,reason:'probe-invalid'};
|
||||
const matches=adapters.filter(a=>typeof a?.name==='string'&&Array.isArray(a.dns)&&vpn.interfaceNames.some(name=>name.toLowerCase()===a.name.toLowerCase()));
|
||||
return matches.some(a=>vpn.requiredDnsServers.every(ip=>a.dns.some(found=>typeof found==='string'&&found.toLowerCase()===ip.toLowerCase())))?{allowed:true,reason:'approved-interface-present'}:{allowed:false,reason:'required-interface-or-dns-missing'};
|
||||
}
|
||||
function probeWindows({platform=process.platform,run=execFile}={}){
|
||||
if(platform!=='win32')return Promise.reject(Error('Network posture is available only on Windows'));
|
||||
const executable=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
|
||||
return new Promise((resolve,reject)=>run(executable,['-NoLogo','-NoProfile','-NonInteractive','-Command',postureScript],{windowsHide:true,timeout:10000,maxBuffer:65536,encoding:'utf8'},(error,stdout)=>{
|
||||
if(error)return reject(Error('Network posture probe unavailable'));
|
||||
try{const value=JSON.parse(stdout.replace(/^\uFEFF/,''));if(!Array.isArray(value))throw Error();resolve(value);}catch{reject(Error('Network posture response invalid'));}
|
||||
}));
|
||||
}
|
||||
function createNetworkPosture({probe=probeWindows}={}){
|
||||
let pending;
|
||||
return {async check(vpn){if(!vpn?.required)return {allowed:true,reason:'not-required'};if(!pending)pending=Promise.resolve().then(probe).finally(()=>{pending=null;});try{return evaluatePosture(vpn,await pending);}catch{return {allowed:false,reason:'probe-unavailable'};}}};
|
||||
}
|
||||
module.exports={resolverOptions,evaluatePosture,probeWindows,createNetworkPosture};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
// This capability stays in the main process. Neither pages nor agents receive CDP access.
|
||||
const owners=new WeakMap();
|
||||
function protectClipboardPrimitives(){
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
const deny=event=>{event.preventDefault();event.stopImmediatePropagation();};
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,deny,true);
|
||||
const transferred=RegExp.prototype.test.bind(/^(insertFromPaste|insertFromDrop|deleteByCut)/);
|
||||
window.addEventListener('beforeinput',event=>{if(transferred(event.inputType||''))deny(event);},true);
|
||||
}
|
||||
const source=`(${protectClipboardPrimitives.toString()})();`;
|
||||
function deadline(operation){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Page security initialization timed out')),5000);})]).finally(()=>clearTimeout(timer));}
|
||||
async function attachPageClipboardGuard(contents,onFailure){
|
||||
const api=contents.debugger;
|
||||
if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable');
|
||||
const owner={api,healthy:false};
|
||||
const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();};
|
||||
owner.detached=detached;
|
||||
api.on('detach',detached);
|
||||
try{
|
||||
api.attach('1.3');
|
||||
await deadline(api.sendCommand('Page.enable',{}));
|
||||
const registration=await deadline(api.sendCommand('Page.addScriptToEvaluateOnNewDocument',{source}));
|
||||
if(typeof registration?.identifier!=='string'||!registration.identifier||!api.isAttached())throw Error('Page security initialization failed');
|
||||
owner.healthy=true;owners.set(contents,owner);
|
||||
}catch{
|
||||
api.removeListener('detach',detached);
|
||||
if(api.isAttached())api.detach();
|
||||
throw Error('Page security initialization failed');
|
||||
}
|
||||
}
|
||||
function guardedScreenshot(contents,parameters){
|
||||
const owner=owners.get(contents);
|
||||
if(!owner)return null;
|
||||
if(!owner.healthy||owner.api!==contents.debugger||!owner.api.isAttached())throw Error('Page security connection unavailable');
|
||||
// Only the fixed screenshot command is shared. There is no general command dispatcher.
|
||||
return owner.api.sendCommand('Page.captureScreenshot',parameters);
|
||||
}
|
||||
function disposePageClipboardGuard(contents){
|
||||
const owner=owners.get(contents);if(!owner)return;
|
||||
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
||||
if(owner.api.isAttached())owner.api.detach();
|
||||
}
|
||||
module.exports={attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard};
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
// Runs only in the isolated main-frame world. No arbitrary selectors or scripts from callers.
|
||||
function fillDocument(expectedUrl,origin,username,password,expiresAt){
|
||||
if(Date.now()>=expiresAt||window.top!==window||!window.isSecureContext||location.href!==expectedUrl||location.origin!==origin)throw Error('Login destination changed');
|
||||
const visible=element=>{
|
||||
const style=getComputedStyle(element);
|
||||
return !element.disabled&&!element.readOnly&&!element.closest('[hidden],[inert],[aria-hidden="true"]')&&style.display!=='none'&&style.visibility!=='hidden'&&Number(style.opacity)!==0&&element.getClientRects().length>0;
|
||||
};
|
||||
const passwords=[...document.querySelectorAll('input[type="password"]')].filter(element=>visible(element)&&element.autocomplete!=='new-password');
|
||||
if(passwords.length!==1)throw Error('A single visible login password field is required');
|
||||
const target=passwords[0],form=target.form;
|
||||
if(!form||form.method.toLowerCase()!=='post'||!['','_self'].includes(form.target.toLowerCase()))throw Error('A same-window POST login form is required');
|
||||
const action=new URL(form.action||location.href,location.href);
|
||||
if(action.protocol!=='https:'||action.origin!==origin||action.username||action.password)throw Error('Login form sends to a different destination');
|
||||
for(const control of [...(form.elements||form.querySelectorAll('button,input'))]){
|
||||
if(control.disabled||typeof control.getAttribute!=='function')continue;
|
||||
const actionOverride=control.getAttribute('formaction'),methodOverride=control.getAttribute('formmethod'),targetOverride=control.getAttribute('formtarget');
|
||||
if(actionOverride!==null){const destination=new URL(actionOverride||location.href,location.href);if(destination.protocol!=='https:'||destination.origin!==origin||destination.username||destination.password)throw Error('Login submit destination differs');}
|
||||
if(methodOverride!==null&&methodOverride.toLowerCase()!=='post')throw Error('Login submit method differs');
|
||||
if(targetOverride!==null&&!['','_self'].includes(targetOverride.toLowerCase()))throw Error('Login submit window differs');
|
||||
}
|
||||
const inputs=[...form.querySelectorAll('input')].filter(element=>visible(element)&&['text','email'].includes(element.type));
|
||||
const named=inputs.filter(element=>element.autocomplete==='username'||/^(?:username|user|email|login)$/i.test(element.name||element.id));
|
||||
const candidates=named.length?named:inputs;
|
||||
if(username&&candidates.length!==1)throw Error('A single login username field is required');
|
||||
const user=username?candidates[0]:null;
|
||||
if(user&&user.form!==form)throw Error('Login fields belong to different forms');
|
||||
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;
|
||||
// Recheck inside the target renderer, after queued native dispatch. A hidden
|
||||
// or blurred document must not receive credentials after a tab switch.
|
||||
if(document.visibilityState!=='visible'||!document.hasFocus())throw Error('Login tab is no longer foreground');
|
||||
// Set both values before dispatching events: page handlers cannot move focus between writes.
|
||||
if(user)setter.call(user,username);setter.call(target,password);
|
||||
if(user)user.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
target.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
return {completed:true};
|
||||
}
|
||||
function fillScript(expectedUrl,credential,expiresAt=Date.now()+10000){
|
||||
const url=new URL(expectedUrl);
|
||||
if(url.protocol!=='https:'||url.username||url.password||url.origin!==credential.origin)throw Error('Saved login requires its exact HTTPS origin');
|
||||
if(!Number.isFinite(expiresAt)||expiresAt<=Date.now())throw Error('Password policy expired');
|
||||
return `(${fillDocument.toString()})(${JSON.stringify(url.href)},${JSON.stringify(credential.origin)},${JSON.stringify(credential.username)},${JSON.stringify(credential.password)},${JSON.stringify(expiresAt)})`;
|
||||
}
|
||||
module.exports={fillDocument,fillScript};
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
// Check the signed effective policy before any enrollment state or browser
|
||||
// settings are committed. A valid fleet signature alone is not device binding.
|
||||
function assertPolicyAssignment(policy, deviceId) {
|
||||
if (policy.assignment && policy.assignment.deviceId !== deviceId) {
|
||||
throw Error('Policy device assignment rejected');
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
module.exports = {assertPolicyAssignment};
|
||||
@@ -0,0 +1,33 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('safetynet', {
|
||||
mutateBookmarks:input=>ipcRenderer.invoke('managed:bookmarks-mutate',input),
|
||||
state:()=>ipcRenderer.invoke('managed:state'),
|
||||
authorizeSetting:section=>ipcRenderer.invoke('managed:authorize-setting',section),
|
||||
updateSettings:input=>ipcRenderer.invoke('managed:settings-update',input),
|
||||
windowControls:action=>ipcRenderer.invoke('managed:window-control',action),
|
||||
setChromeLayout:input=>ipcRenderer.invoke('managed:chrome-layout',input),
|
||||
shareURL:()=>ipcRenderer.invoke('managed:share-url'),
|
||||
enroll:input=>ipcRenderer.invoke('managed:enroll',input),
|
||||
sso:input=>ipcRenderer.invoke('managed:sso',input),
|
||||
navigate:url=>ipcRenderer.invoke('managed:navigate',url),
|
||||
createTab:input=>ipcRenderer.invoke('managed:tab-create',input),
|
||||
activateTab:id=>ipcRenderer.invoke('managed:tab-activate',id),
|
||||
closeTab:id=>ipcRenderer.invoke('managed:tab-close',id),
|
||||
groupTab:input=>ipcRenderer.invoke('managed:tab-group',input),
|
||||
back:()=>ipcRenderer.invoke('managed:back'),forward:()=>ipcRenderer.invoke('managed:forward'),reload:()=>ipcRenderer.invoke('managed:reload'),
|
||||
ai:prompt=>ipcRenderer.invoke('managed:ai',prompt),
|
||||
inspectFile:()=>ipcRenderer.invoke('managed:inspect-file'),
|
||||
agentConnection:()=>ipcRenderer.invoke('managed:agent-connection'),
|
||||
toggleAI:open=>ipcRenderer.invoke('managed:ai-panel',open),
|
||||
toggleVault:open=>ipcRenderer.invoke('managed:vault-panel',open),
|
||||
vaultStatus:()=>ipcRenderer.invoke('managed:vault-status'),
|
||||
vaultCreate:master=>ipcRenderer.invoke('managed:vault-create',master),
|
||||
vaultUnlock:master=>ipcRenderer.invoke('managed:vault-unlock',master),
|
||||
vaultLock:()=>ipcRenderer.invoke('managed:vault-lock'),
|
||||
vaultList:()=>ipcRenderer.invoke('managed:vault-list'),
|
||||
vaultSave:entry=>ipcRenderer.invoke('managed:vault-save',entry),
|
||||
vaultDelete:id=>ipcRenderer.invoke('managed:vault-delete',id),
|
||||
vaultFill:id=>ipcRenderer.invoke('managed:vault-fill',id),
|
||||
vaultGenerate:()=>ipcRenderer.invoke('managed:vault-generate'),
|
||||
onState:callback=>{const listener=(_event,data)=>callback(data);ipcRenderer.on('managed:state',listener);return()=>ipcRenderer.removeListener('managed:state',listener);}
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const $=id=>document.getElementById(id);let panel=false,vaultPanel=false,lastState,vaultState,vaultBusy=false,vaultRevision=0;
|
||||
let sidebarOpen=false,startPageOpen=false,overviewOpen=false,tabs=[],activeTab=null,groupFilter='',navigationRevision=0,pendingNavigation=null,lastLayoutKey='',lastTabsKey='',lastLibraryKey='',nativeTabsRevision=-1;
|
||||
const preference=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(`sbrowser.${key}`))??fallback;}catch{return fallback;}};
|
||||
const remember=(key,value)=>{try{localStorage.setItem(`sbrowser.${key}`,JSON.stringify(value));}catch{}};
|
||||
const safeURL=value=>{try{const u=new URL(value);if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;u.search='';u.hash='';return u.href;}catch{return null;}};
|
||||
const metadata=list=>Array.isArray(list)?list.slice(0,100).filter(v=>v&&safeURL(v.url)&&typeof v.title==='string').map(v=>({url:safeURL(v.url),title:v.title.slice(0,160)})):[];
|
||||
let bookmarks=[],readingList=metadata(preference('reading-list',[])),groups=preference('groups',[]);groups=Array.isArray(groups)?groups.filter(v=>typeof v==='string'&&v.length<=40).slice(0,20):[];
|
||||
function currentTab(){return tabs.find(t=>t.id===activeTab)||{id:null,url:'',title:'New Tab',group:'',startPage:true};}
|
||||
async function updateChrome(){
|
||||
document.body.classList.toggle('sidebar-open',sidebarOpen);if($('library'))$('library').hidden=!sidebarOpen;if($('start-page'))$('start-page').hidden=!startPageOpen||overviewOpen||!lastState?.ready;if($('overview'))$('overview').hidden=!overviewOpen||!lastState?.ready;
|
||||
for(const id of ['bookmark','reading-add','share-url'])if($(id))$(id).disabled=!lastState?.ready||startPageOpen||overviewOpen||!currentTab().url||(id==='bookmark'&&(!sectionEditable('library')||!lastState?.bookmarks?.allowUserAdd))||(id==='reading-add'&&!sectionEditable('library'));
|
||||
const height=Math.ceil($('chrome')?.getBoundingClientRect().bottom||104),layout={height,sidebarOpen,startPageOpen:startPageOpen&&!!lastState?.ready,overviewOpen:overviewOpen&&!!lastState?.ready,toolsOpen:!!($('tools-menu')&&!$('tools-menu').hidden)||!!$('bookmark-editor')?.open},key=JSON.stringify(layout);document.documentElement.style.setProperty('--chrome-height',`${height}px`);if(window.safetynet.setChromeLayout&&key!==lastLayoutKey){lastLayoutKey=key;try{await call('setChromeLayout',layout);}catch(e){lastLayoutKey='';throw e;}}
|
||||
}
|
||||
async function navigateTab(value){
|
||||
const token=++navigationRevision,tab=currentTab();if(!tab.id)throw Error('No managed tab is available');pendingNavigation={token,tabId:tab.id};overviewOpen=false;
|
||||
try{const result=await call('navigate',{url:value,tabId:tab.id});if(token===navigationRevision)show(result);}
|
||||
finally{if(pendingNavigation?.token===token)pendingNavigation=null;}
|
||||
}
|
||||
// Native WebContents own identity, history and lifetime. Switching never reloads a URL.
|
||||
async function selectTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;overviewOpen=false;show(await call('activateTab',id));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();}
|
||||
async function newTab(){navigationRevision++;overviewOpen=false;show(await call('createTab',{group:groupFilter}));$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();$('address').focus();}
|
||||
async function closeTab(id){if(!tabs.some(t=>t.id===id))return;navigationRevision++;const previous=activeTab;show(await call('closeTab',id));if(previous!==activeTab)$('address').value=currentTab().startPage?'':currentTab().url;await updateChrome();}
|
||||
function button(text,fn,cls=''){const b=document.createElement('button');b.type='button';b.className=cls;b.textContent=text;b.onclick=task(fn);return b;}
|
||||
function renderTabs(){
|
||||
const strip=$('tab-strip');if(!strip)return;const key=JSON.stringify({tabs,activeTab,groupFilter,ready:!!lastState?.ready});if(key===lastTabsKey)return;lastTabsKey=key;strip.replaceChildren();for(const tab of tabs.filter(t=>!groupFilter||t.group===groupFilter)){
|
||||
const el=document.createElement('div');el.className=`browser-tab${tab.id===activeTab?' active':''}`;el.setAttribute('role','tab');el.setAttribute('aria-selected',String(tab.id===activeTab));el.title=tab.url||'New Tab';
|
||||
const select=button(tab.title||'New Tab',()=>selectTab(tab.id),'tab-label'),close=button('×',()=>closeTab(tab.id),'tab-close');close.setAttribute('aria-label',`Close ${tab.title}`);select.disabled=!lastState?.ready;close.disabled=!lastState?.ready;el.dataset.tabId=tab.id;el.append(select,close);strip.append(el);}
|
||||
if($('overview-grid')){$('overview-grid').replaceChildren();for(const tab of tabs){const card=button('',()=>selectTab(tab.id),'overview-card');const title=document.createElement('strong'),url=document.createElement('span');title.textContent=tab.title;url.textContent=safeURL(tab.url)||'Favorites and a fresh start';card.append(title,url);$('overview-grid').append(card);}}
|
||||
}
|
||||
function renderLinks(id,items){if(!$(id))return;$(id).replaceChildren();for(const item of items){const b=button('',()=>navigateTab(item.url),'favorite-card');const initial=document.createElement('span'),label=document.createElement('strong');initial.className='favorite-icon';initial.textContent=new URL(item.url).hostname.charAt(0).toUpperCase();label.textContent=item.title||new URL(item.url).hostname;if(item.managed){const badge=document.createElement('small');badge.textContent='Company';b.append(badge);}b.title=item.url;b.append(initial,label);$(id).append(b);}}
|
||||
let bookmarkEditingId=null;
|
||||
async function openBookmarkEditor(item){
|
||||
requireSection('library',true);const rules=lastState?.bookmarks;
|
||||
if(item?.managed||!(item?rules?.allowUserEdit:rules?.allowUserAdd))throw Error('This bookmark is managed by your administrator');
|
||||
bookmarkEditingId=item?.id||null;$('bookmark-editor-title').textContent=item?'Edit bookmark':'Add bookmark';
|
||||
$('bookmark-title').value=(item?.title||lastState?.pageTitle||'New bookmark').slice(0,120);$('bookmark-url').value=item?.url||safeURL(lastState?.url)||'';$('bookmark-folder').value=item?.folder||'';
|
||||
$('bookmark-editor-error').textContent='';$('bookmark-editor').showModal();await updateChrome();$('bookmark-title').focus();
|
||||
}
|
||||
async function mutateBookmark(action,bookmark,id){
|
||||
const snapshot=lastState;const result=await call('mutateBookmarks',{action,...(bookmark?{bookmark}:{}),...(id?{id}:{}),expectedRevision:snapshot.bookmarks.personal.revision,policyVersion:snapshot.policyVersion});show({...snapshot,...result});
|
||||
}
|
||||
function renderBookmarkLibrary(){
|
||||
const target=$('library-bookmarks');target.replaceChildren();
|
||||
for(const [managed,label] of [[true,'Company bookmarks (managed)'],[false,'Your bookmarks']]){
|
||||
const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent=label;target.append(heading);
|
||||
let folder=null;for(const item of bookmarks.filter(item=>!!item.managed===managed).sort((a,b)=>(a.folder||'').localeCompare(b.folder||''))){
|
||||
if(item.folder&&item.folder!==folder){const group=document.createElement('small');group.textContent=item.folder;target.append(group);}folder=item.folder;
|
||||
const row=document.createElement('div');row.className='bookmark-row';const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;row.append(link);
|
||||
if(!managed&§ionEditable('library')){if(lastState?.bookmarks?.allowUserEdit){const edit=button('Edit',()=>openBookmarkEditor(item),'bookmark-action');edit.setAttribute('aria-label',`Edit ${item.title}`);row.append(edit);}if(lastState?.bookmarks?.allowUserDelete){const remove=button('Remove',()=>mutateBookmark('delete',null,item.id),'bookmark-action');remove.setAttribute('aria-label',`Remove ${item.title}`);row.append(remove);}}
|
||||
target.append(row);
|
||||
}
|
||||
}
|
||||
const legacy=metadata(preference('bookmarks',[]));
|
||||
if(legacy.length&§ionVisible('library')){const heading=document.createElement('p');heading.className='bookmark-section-label';heading.textContent='Previous local bookmarks';target.append(heading);const note=document.createElement('small');note.textContent='These existing links remain local. Add them to your bookmarks to sync them when company policy permits.';target.append(note);for(const item of legacy){const link=button(item.title,()=>navigateTab(item.url),'bookmark-link');link.title=item.url;target.append(link);}}
|
||||
}
|
||||
$('bookmark-cancel').onclick=()=>$('bookmark-editor').close();
|
||||
$('bookmark-editor').addEventListener('close',()=>updateChrome().catch(()=>{}));
|
||||
$('bookmark-form').onsubmit=async event=>{event.preventDefault();const submit=$('bookmark-save');submit.disabled=true;try{
|
||||
requireSection('library',true);await mutateBookmark(bookmarkEditingId?'update':'add',{id:bookmarkEditingId||crypto.randomUUID(),title:$('bookmark-title').value,url:$('bookmark-url').value,folder:$('bookmark-folder').value});$('bookmark-editor').close();
|
||||
}catch(e){$('bookmark-editor-error').textContent=e.message;}finally{submit.disabled=false;}};
|
||||
function renderLibrary(){
|
||||
const key=JSON.stringify({bookmarks,bookmarkPermissions:lastState?.bookmarks,settings:lastState?.settings,readingList,groups,tabs,activeTab});if(key===lastLibraryKey)return;lastLibraryKey=key;
|
||||
const favorites=sectionVisible('library')?bookmarks.slice(0,12):[];renderLinks('favorites-grid',favorites);renderLinks('library-favorites',favorites);renderBookmarkLibrary();renderLinks('library-reading',readingList);
|
||||
if($('library-groups')){$('library-groups').replaceChildren();$('library-groups').append(button('All tabs',()=>{groupFilter='';renderTabs();},'group-item'));for(const group of groups)$('library-groups').append(button(group,()=>{groupFilter=group;renderTabs();},'group-item'));}
|
||||
if($('favorites-empty'))$('favorites-empty').hidden=!!favorites.length;
|
||||
if($('library-tabs')){$('library-tabs').replaceChildren();for(const tab of tabs)$('library-tabs').append(button(tab.title,()=>selectTab(tab.id),`library-tab${tab.id===activeTab?' active':''}`));}
|
||||
renderLinks('recent-list',tabs.filter(t=>t.url).slice(-5).map(t=>({url:safeURL(t.url),title:t.title})).filter(t=>t.url));
|
||||
}
|
||||
const systemAppearance=window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const systemIsDark=()=>window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const accentPresets={blue:{base:'#0969da',light:'#0969da',dark:'#75baff'},teal:{base:'#007d76',light:'#00736d',dark:'#6cdbca'},violet:{base:'#7042c1',light:'#7042c1',dark:'#c5a4ff'}};
|
||||
function applyAppearance(){
|
||||
// Appearance comes exclusively from the privileged process, including unlocked saved choices.
|
||||
const appearance=lastState?.settings?.appearance||{mode:'system',accent:'blue',tabMode:'separate'},theme=appearance.mode==='system'?'auto':appearance.mode,mode=appearance.tabMode;
|
||||
const effective=theme==='auto'?(systemIsDark()?'dark':'light'):theme,savedAccent=appearance.accent,accent=typeof savedAccent==='string'&&Object.hasOwn(accentPresets,savedAccent)?savedAccent:'blue';
|
||||
document.documentElement.dataset.theme=theme;document.documentElement.dataset.effectiveTheme=effective;document.documentElement.dataset.accent=accent;document.documentElement.dataset.tabMode=mode==='compact'?'compact':'separate';
|
||||
document.body.dataset.theme=effective;document.body.dataset.tabMode=document.documentElement.dataset.tabMode;document.documentElement.style.colorScheme=effective;
|
||||
document.documentElement.style.setProperty('--accent',accentPresets[accent].base);document.documentElement.style.setProperty('--accent-text',accentPresets[accent][effective]);
|
||||
if($('theme-mode'))$('theme-mode').value=theme;if($('tab-mode'))$('tab-mode').value=document.documentElement.dataset.tabMode;if($('accent-mode'))$('accent-mode').value=accent;
|
||||
if($('theme-status'))$('theme-status').textContent=theme==='auto'?`System · ${effective==='dark'?'Dark':'Light'}. Changes follow your device.`:`${effective==='dark'?'Dark':'Light'} theme · browser workspace only.`;
|
||||
updateChrome().catch(()=>{});
|
||||
}
|
||||
systemAppearance.addEventListener('change',()=>{if((!lastState?.settings||lastState.settings.appearance.mode==='system'))applyAppearance();});
|
||||
function refreshSystemAppearance(){if((!lastState?.settings||lastState.settings.appearance.mode==='system')&&document.body.dataset.theme!==(systemIsDark()?'dark':'light'))applyAppearance();}
|
||||
window.addEventListener('focus',refreshSystemAppearance);
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refreshSystemAppearance();});
|
||||
// Some Windows compositor/theme transitions update the query without a change event.
|
||||
setInterval(refreshSystemAppearance,1000);
|
||||
|
||||
|
||||
// Hidden controls are removed from keyboard navigation as well as visual presentation.
|
||||
// Native IPC repeats every check: DOM visibility is never the security boundary.
|
||||
function sectionVisible(name){const settings=lastState?.settings;return !!settings?.available&&(settings.visible||['library','ai','passwords','documentScan','agents'].includes(name))&&!settings.hiddenSections.includes(name);}
|
||||
function sectionEditable(name){return sectionVisible(name)&&(name!=='library'||lastState.settings.visible)&&!lastState.settings.lockedSections.includes(name);}
|
||||
function requireSection(name,edit=false){if(!(edit?sectionEditable(name):sectionVisible(name)))throw Error('This action is managed by your administrator.');}
|
||||
function applyManagedSettings(){
|
||||
const settings=lastState?.settings,visible=!!settings?.available&&settings.visible;
|
||||
$('tools-toggle').hidden=!visible;$('tools-toggle').disabled=!visible;
|
||||
if(!visible){$('tools-menu').hidden=true;$('tools-toggle').setAttribute('aria-expanded','false');}
|
||||
const sections={appearance:['appearance-section'],library:['bookmark','reading-add','sidebar-toggle'],ai:['ai-toggle','sidebar'],passwords:['passwords'],documentScan:['inspect'],agents:['agents']};
|
||||
for(const [name,ids] of Object.entries(sections))for(const id of ids){const el=$(id);if(!el)continue;el.hidden=!sectionVisible(name);if(!sectionVisible(name)||(['library','ai','passwords','documentScan','agents'].includes(name)&&!sectionEditable(name)))el.disabled=true;}
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).disabled=!sectionEditable('appearance');
|
||||
for(const id of ['bookmark','reading-add','group-add','group-name'])if($(id))$(id).disabled=!sectionEditable('library')||!lastState?.ready||(id==='bookmark'&&!lastState?.bookmarks?.allowUserAdd);
|
||||
$('send').disabled=!lastState?.aiEnabled||!sectionEditable('ai');$('prompt').disabled=!sectionEditable('ai');
|
||||
for(const [quick,original,name,enabled] of [['quick-ai','ai-toggle','ai',lastState?.aiEnabled],['quick-passwords','passwords','passwords',lastState?.ready&&lastState?.passwordManagerEnabled!==false],['quick-inspect','inspect','documentScan',lastState?.documentScanEnabled],['quick-agents','agents','agents',lastState?.agentsEnabled]]){const el=$(quick);el.hidden=visible||!sectionVisible(name)||!enabled;el.disabled=!sectionEditable(name)||!enabled;el.setAttribute('aria-expanded',$(original).getAttribute('aria-expanded')||'false');}
|
||||
$('settings-managed').hidden=!sectionVisible('appearance')||sectionEditable('appearance');
|
||||
if(!sectionVisible('library')){sidebarOpen=false;$('sidebar-toggle').setAttribute('aria-expanded','false');}
|
||||
if(!sectionEditable('ai')&&panel)closeAI().catch(()=>{});
|
||||
if(!sectionEditable('passwords')&&vaultPanel)closeVault().catch(()=>{});
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
async function call(method,...args){const r=await window.safetynet[method](...args);if(!r.ok)throw Error(r.error);return r.data;}
|
||||
function clearVaultFields(){for(const id of ['vault-master','vault-confirm','vault-password','vault-origin','vault-label','vault-username'])$(id).value='';}
|
||||
async function closeVault(){vaultRevision++;vaultPanel=false;$('vault').hidden=true;$('passwords').setAttribute('aria-expanded','false');clearVaultFields();$('vault-list').replaceChildren();$('vault-add').open=false;await call('vaultLock');await call('toggleVault',false);}
|
||||
async function closeAI(){panel=false;$('ai').hidden=true;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','false');await call('toggleAI',false);}
|
||||
async function openAI(){requireSection('ai',true);if(vaultPanel)await closeVault();panel=true;$('ai').hidden=false;for(const id of ['sidebar','ai-toggle'])$(id).setAttribute('aria-expanded','true');await call('toggleAI',true);}
|
||||
function show(s){
|
||||
// A delayed invoke response must not replace a newer tab switch or navigation event.
|
||||
if(Number.isSafeInteger(s.tabsRevision)&&s.tabsRevision<nativeTabsRevision)return;
|
||||
if(Array.isArray(s.tabs)){nativeTabsRevision=s.tabsRevision??nativeTabsRevision;tabs=s.tabs;activeTab=s.activeTabId;startPageOpen=!!currentTab().startPage;if(groupFilter&¤tTab().group!==groupFilter)groupFilter=currentTab().group;}
|
||||
document.body.classList.toggle('window-maximized',!!s.windowMaximized);
|
||||
lastState=s;bookmarks=[...(s.bookmarks?.managed||[]).map(item=>({...item,managed:true})),...(s.bookmarks?.personal?.items||[])];if(!s.ready||!sectionEditable('library')||($('bookmark-editor').open&&!(bookmarkEditingId?s.bookmarks?.allowUserEdit:s.bookmarks?.allowUserAdd)))$('bookmark-editor').close();const brand=s.brand.name==='SafetyNet'?'S Browser':s.brand.name;$('brand').textContent=brand;document.title=brand;$('managed').textContent=s.ready?(s.loginCheckPending?'Login check required':`Managed · policy ${s.policyVersion}`):'Access paused';$('managed').parentElement.classList.toggle('paused',!s.ready);
|
||||
$('welcome').hidden=s.ready;$('enrollment').hidden=s.enrolled;$('message').textContent=s.message;
|
||||
if(document.activeElement!==$('address'))$('address').value=startPageOpen?'':s.url||'';
|
||||
$('managed').title=s.message;$('address-form').querySelector('.address-lock').src=s.url?.startsWith('https:')?'icons/lock-keyhole.svg':'icons/globe.svg';
|
||||
$('ai-toggle').disabled=!s.aiEnabled;$('sidebar').disabled=!s.ready;$('send').disabled=!s.aiEnabled;
|
||||
$('agents').disabled=!s.agentsEnabled;$('inspect').disabled=!s.documentScanEnabled;$('passwords').disabled=!s.ready||s.passwordManagerEnabled===false;
|
||||
$('inspect').title=s.documentScanEnabled?'Inspect a document locally':'Document analysis is disabled by company policy';
|
||||
$('back').disabled=!s.ready||!currentTab().canGoBack;$('forward').disabled=!s.ready||!currentTab().canGoForward;$('reload').disabled=!s.ready||currentTab().startPage;$('address').disabled=!(s.canNavigate??s.ready);
|
||||
if(vaultPanel&&s.vaultLocked===true&&vaultState?.locked!==true){vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();$('vault-open').hidden=true;vaultState={...vaultState,locked:true};refreshVault().catch(()=>{});}
|
||||
if(vaultPanel&&s.passwordManagerEnabled===false)closeVault().catch(()=>{});
|
||||
if(!s.ready){if(panel)closeAI().catch(()=>{});if(vaultPanel)closeVault().catch(()=>{});clearVaultFields();}
|
||||
document.documentElement.style.setProperty('--page-tint',/^#[a-f0-9]{6}$/i.test(s.pageColor||'')?s.pageColor:'#b8c7dd');
|
||||
if($('start-greeting'))$('start-greeting').textContent='A fresh place to begin';
|
||||
if($('sidebar-toggle'))$('sidebar-toggle').disabled=!s.ready;
|
||||
if($('new-tab'))$('new-tab').disabled=!s.ready;if($('tab-overview'))$('tab-overview').disabled=!s.ready;
|
||||
applyManagedSettings();updateChrome().catch(()=>{});renderTabs();renderLibrary();
|
||||
}
|
||||
function task(fn){return async event=>{event?.preventDefault();$('error').textContent='';try{await fn();}catch(e){$('error').textContent=e.message;$('managed').textContent=e.message;}};}
|
||||
function vaultTask(fn){return async event=>{event?.preventDefault();if(vaultBusy)return;vaultBusy=true;$('vault-error').textContent='';try{await fn();}catch{$('vault-error').textContent='The vault action could not be completed. Check your password and company policy.';}finally{vaultBusy=false;$('vault-master').value='';$('vault-confirm').value='';}};}
|
||||
function input(){return {server:$('server').value,code:$('code').value};}
|
||||
$('enroll').onclick=task(async()=>{$('enroll').disabled=true;try{show(await call('enroll',input()));$('code').value='';}finally{$('enroll').disabled=false;}});
|
||||
$('sso').onclick=task(async()=>{$('sso').disabled=true;try{show(await call('sso',input()));}finally{$('sso').disabled=false;}});
|
||||
$('address-form').onsubmit=task(async()=>navigateTab($('address').value));
|
||||
for(const method of ['back','forward','reload'])$(method).onclick=task(()=>call(method));
|
||||
$('ai-toggle').onclick=$('sidebar').onclick=task(async()=>{if(panel)await closeAI();else await openAI();});
|
||||
$('ai-close').onclick=task(closeAI);
|
||||
$('inspect').onclick=task(async()=>{
|
||||
requireSection('documentScan',true);$('inspect').disabled=true;try{const result=await call('inspectFile');if(!result)return;await openAI();entry(`Inspection: ${result.name}\nDecision: ${result.decision}\nDetector: ${result.ruleId||'No match'}\nType: ${result.contentType}\nComplete coverage: ${result.coverage.complete}\n${result.model?`Model: ${result.model}`:''}\nAnalyzed locally. This result does not authorize a website upload.`);}finally{show(await call('state'));}
|
||||
});
|
||||
function entry(text){const empty=$('conversation').querySelector('.empty-chat');empty?.remove();const e=document.createElement('div');e.className='entry';e.textContent=text;$('conversation').append(e);e.scrollIntoView();}
|
||||
$('agents').onclick=task(async()=>{requireSection('agents',true);const connection=await call('agentConnection');await openAI();entry(`Local agent API\nEndpoint: ${connection.address}\nEncrypted pairing file: ${connection.credentialPath}\nUse the trusted local helper. Every action follows company policy.`);});
|
||||
$('ai-form').onsubmit=task(async()=>{const prompt=$('prompt').value;if(!prompt.trim())return;entry(`You: ${prompt}`);$('send').disabled=true;try{const result=await call('ai',prompt);entry(result.answer);$('prompt').value='';}catch(e){entry(e.message);}finally{$('send').disabled=!lastState?.aiEnabled;}});
|
||||
async function refreshVault(){
|
||||
const revision=++vaultRevision;const status=await call('vaultStatus');if(!vaultPanel||revision!==vaultRevision)return;const previous=vaultState;vaultState=status;
|
||||
$('vault-access').hidden=!status.enabled||!status.locked&&status.exists;$('vault-open').hidden=!status.enabled||status.locked||!status.exists;
|
||||
$('vault-access-submit').textContent=status.exists?'Unlock vault':'Create vault';$('vault-master').autocomplete=status.exists?'current-password':'new-password';
|
||||
$('vault-confirm').hidden=status.exists;$('vault-confirm-label').hidden=status.exists;$('vault-confirm').required=!status.exists&&status.enabled;
|
||||
$('master-label').textContent=status.exists?'Master password':'Choose a master password';
|
||||
$('vault-description').textContent=!status.enabled?'Password storage is disabled by your administrator.':status.locked||!status.exists?'Your encrypted logins stay on this device. Unlock with your master password.':`Unlocked · locks after ${status.idleLockSeconds} seconds of inactivity.`;
|
||||
$('vault-add').hidden=!status.allowSaving;$('vault-save-submit').disabled=!status.allowSaving;$('vault-generate').disabled=!status.allowSaving;
|
||||
if(!status.enabled||status.locked||!status.exists){if(!status.enabled||previous?.locked===false)clearVaultFields();$('vault-list').replaceChildren();return;}
|
||||
const entries=await call('vaultList');if(!vaultPanel||revision!==vaultRevision)return;$('vault-list').replaceChildren();
|
||||
if(!entries.length){const empty=document.createElement('div');empty.className='empty-vault';empty.textContent='No saved logins yet.';$('vault-list').append(empty);}
|
||||
for(const item of entries){const card=document.createElement('div');card.className='login-card';const label=document.createElement('strong');label.textContent=item.label||item.origin;const origin=document.createElement('p');origin.textContent=item.origin;const username=document.createElement('p');username.textContent=item.username||'No username';const buttons=document.createElement('div');buttons.className='login-buttons';const fill=document.createElement('button');fill.textContent='Fill login';fill.disabled=!status.allowAutofill;fill.onclick=vaultTask(async()=>{await call('vaultFill',item.id);$('vault-error').textContent='Login filled in the current page.';});const remove=document.createElement('button');remove.className='secondary';remove.textContent='Delete';remove.disabled=!status.allowSaving;remove.onclick=vaultTask(async()=>{await call('vaultDelete',item.id);await refreshVault();});buttons.append(fill,remove);card.append(label,origin,username,buttons);$('vault-list').append(card);}
|
||||
}
|
||||
$('passwords').onclick=vaultTask(async()=>{requireSection('passwords',true);if(vaultPanel){await closeVault();return;}await closeAI();vaultPanel=true;$('vault').hidden=false;$('passwords').setAttribute('aria-expanded','true');await call('toggleVault',true);await refreshVault();});
|
||||
$('vault-close').onclick=vaultTask(closeVault);
|
||||
$('vault-access').onsubmit=vaultTask(async()=>{const exists=vaultState?.exists,master=$('vault-master').value;if(!exists&&master!==$('vault-confirm').value){$('vault-error').textContent='The master passwords do not match.';return;}await call(exists?'vaultUnlock':'vaultCreate',master);$('vault-master').value='';$('vault-confirm').value='';await refreshVault();});
|
||||
$('vault-lock').onclick=vaultTask(async()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();await call('vaultLock');await refreshVault();});
|
||||
$('vault-save').onsubmit=vaultTask(async()=>{await call('vaultSave',{origin:$('vault-origin').value,label:$('vault-label').value,username:$('vault-username').value,password:$('vault-password').value});clearVaultFields();$('vault-add').open=false;await refreshVault();});
|
||||
$('vault-generate').onclick=vaultTask(async()=>{const generated=await call('vaultGenerate');$('vault-password').value=typeof generated==='string'?generated:generated.password;});
|
||||
$('vault-add').ontoggle=()=>{if($('vault-add').open&&lastState?.url&&!$('vault-origin').value){try{$('vault-origin').value=new URL(lastState.url).origin;}catch{}}else if(!$('vault-add').open)$('vault-password').value='';};
|
||||
window.addEventListener('blur',()=>{vaultRevision++;clearVaultFields();$('vault-list').replaceChildren();if(vaultPanel)call('vaultLock').then(refreshVault).catch(()=>{});});
|
||||
window.addEventListener('pagehide',()=>{clearVaultFields();if(vaultPanel)call('vaultLock').catch(()=>{});});
|
||||
setInterval(()=>{if(vaultPanel&&!vaultBusy)refreshVault().catch(()=>{clearVaultFields();$('vault-list').replaceChildren();});},5000);
|
||||
function wire(id,handler){if($(id))$(id).onclick=task(handler);}
|
||||
wire('sidebar-toggle',async()=>{requireSection('library');sidebarOpen=!sidebarOpen;$('sidebar-toggle').setAttribute('aria-expanded',String(sidebarOpen));await updateChrome();});
|
||||
wire('new-tab',newTab);wire('tab-overview',async()=>{overviewOpen=!overviewOpen;startPageOpen=!!currentTab().startPage;$('tab-overview').setAttribute('aria-expanded',String(overviewOpen));renderTabs();await updateChrome();});
|
||||
wire('overview-close',async()=>{overviewOpen=false;startPageOpen=!!currentTab().startPage;await updateChrome();});
|
||||
for(const action of ['minimize','maximize','close'])wire(`window-${action}`,()=>call('windowControls',action));
|
||||
wire('share-url',()=>call('shareURL'));
|
||||
wire('bookmark',async()=>{requireSection('library',true);if(!lastState?.bookmarks?.allowUserAdd)throw Error('Adding bookmarks is disabled by your administrator');await openBookmarkEditor();});
|
||||
wire('reading-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);if(!lastState?.ready)throw Error('Open an approved page first');const url=safeURL(lastState.url);if(!url)throw Error('This page cannot be saved');if(!readingList.some(b=>b.url===url)){readingList.push({url,title:String(lastState.pageTitle||new URL(url).hostname).slice(0,160)});readingList=readingList.slice(-100);remember('reading-list',readingList);renderLibrary();}});
|
||||
wire('group-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);const field=$('group-name'),label=field?.value.trim();if(!label)throw Error('Enter a tab group name');if(label.length>40)throw Error('Tab group names are limited to 40 characters');if(!groups.includes(label)){if(groups.length>=20)throw Error('At most 20 tab groups are supported');groups.push(label);remember('groups',groups);}show(await call('groupTab',{id:currentTab().id,group:label}));groupFilter=label;field.value='';renderLibrary();renderTabs();});
|
||||
for(const [quick,original] of [['quick-ai','ai-toggle'],['quick-passwords','passwords'],['quick-inspect','inspect'],['quick-agents','agents']])wire(quick,()=>$(original).click());
|
||||
wire('tools-toggle',async()=>{if(!lastState?.settings?.available||!lastState.settings.visible)throw Error('Settings are managed by your administrator.');const menu=$('tools-menu');if(menu){menu.hidden=!menu.hidden;$('tools-toggle').setAttribute('aria-expanded',String(!menu.hidden));await updateChrome();}});
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).onchange=task(async()=>{
|
||||
requireSection('appearance',true);
|
||||
try{const settings=await call('updateSettings',{mode:$('theme-mode').value==='auto'?'system':$('theme-mode').value,accent:$('accent-mode').value,tabMode:$('tab-mode').value});lastState={...lastState,settings};}
|
||||
finally{applyManagedSettings();}
|
||||
});
|
||||
window.addEventListener('resize',()=>updateChrome().catch(()=>{}));
|
||||
document.addEventListener('click',event=>{const menu=$('tools-menu');if(!menu||menu.hidden)return;if(event.target.closest('.menu-item')||(!event.target.closest('#tools-menu')&&!event.target.closest('#tools-toggle'))){menu.hidden=true;$('tools-toggle')?.setAttribute('aria-expanded','false');updateChrome().catch(()=>{});}});
|
||||
window.addEventListener('keydown',event=>{if(event.ctrlKey&&event.key.toLowerCase()==='t'&&lastState?.ready){event.preventDefault();newTab().catch(()=>{});}if(event.key==='Escape'&&overviewOpen){overviewOpen=false;startPageOpen=!!currentTab().startPage;updateChrome().catch(()=>{});}});
|
||||
applyAppearance();renderTabs();renderLibrary();
|
||||
window.safetynet.onState(show);call('state').then(show).catch(e=>{$('error').textContent=e.message;});
|
||||
@@ -0,0 +1,50 @@
|
||||
const {ipcRenderer}=require('electron');
|
||||
if(process.sandboxed!==true||process.contextIsolated!==true){ipcRenderer.send('managed:clipboard-sandbox-failure');window.stop();throw Error('Managed renderer sandbox and isolation are required');}
|
||||
// Native shortcuts and guarded menus are the only supported clipboard transfer paths.
|
||||
// Prevent page handlers/legacy execCommand from replacing approved plaintext with hidden data.
|
||||
// Freeze the legacy command primitive in the page realm. Document.open() removes
|
||||
// DOM listeners, but cannot replace this non-configurable prototype property.
|
||||
try {
|
||||
require('electron').contextBridge.executeInMainWorld({func:()=>{
|
||||
const prototype=Document.prototype,existing=Object.getOwnPropertyDescriptor(prototype,'execCommand');
|
||||
if(existing?.value?._managedClipboardGuard===true&&!existing.configurable)return;
|
||||
const original=prototype.execCommand,apply=Reflect.apply,test=RegExp.prototype.test.bind(/^(copy|cut|paste)/i);
|
||||
function managedClipboardCommand(command,...args){
|
||||
if(typeof command!=='string'||test(command))return false;
|
||||
return apply(original,this,[command,...args]);
|
||||
}
|
||||
Object.defineProperty(managedClipboardCommand,'_managedClipboardGuard',{value:true});
|
||||
Object.defineProperty(prototype,'execCommand',{value:managedClipboardCommand,writable:false,configurable:false});
|
||||
// Replacement clears security listeners; these unsupported legacy APIs fail closed.
|
||||
const SecurityException=DOMException;
|
||||
const denyReplacement=()=>{throw new SecurityException('Managed document replacement is disabled','SecurityError');};
|
||||
for(const name of ['open','write','writeln'])Object.defineProperty(prototype,name,{value:denyReplacement,writable:false,configurable:false});
|
||||
}});
|
||||
} catch {
|
||||
require('electron').ipcRenderer.send('managed:clipboard-sandbox-failure');
|
||||
window.stop();
|
||||
throw Error('Managed clipboard primitive could not be protected');
|
||||
}
|
||||
for(const name of ['copy','cut','paste','dragstart','dragover','drop'])window.addEventListener(name,event=>{event.preventDefault();event.stopImmediatePropagation();},true);
|
||||
window.addEventListener('beforeinput',event=>{if(/^(insertFromPaste|insertFromDrop|deleteByCut)/.test(event.inputType||'')){event.preventDefault();event.stopImmediatePropagation();}},true);
|
||||
let visualEnabled=ipcRenderer.sendSync('managed:visual-policy').enabled;
|
||||
const documentToken=typeof crypto.randomUUID==='function'?crypto.randomUUID():Array.from(crypto.getRandomValues(new Uint8Array(16)),value=>value.toString(16).padStart(2,'0')).join('');let visualRevision=0,visualApproved=!visualEnabled,visualScheduled=false;
|
||||
ipcRenderer.on('managed:visual-config',(_event,input)=>{visualEnabled=input.enabled===true;visualApproved=!visualEnabled;visualRevision++;visualChanged();ipcRenderer.send('managed:visual-configured',input.token);});
|
||||
function visualChanged(){
|
||||
if(!visualEnabled)return;visualRevision++;visualApproved=false;
|
||||
if(visualScheduled)return;visualScheduled=true;
|
||||
setTimeout(async()=>{visualScheduled=false;const revision=visualRevision,url=location.href;
|
||||
try{const result=await ipcRenderer.invoke('managed:visual-check',{token:documentToken,revision,url});if(revision===visualRevision&&url===location.href)visualApproved=result.decision==='allow';}catch{visualApproved=false;}
|
||||
if(!visualApproved&&revision===visualRevision)setTimeout(visualChanged,1000);
|
||||
},150);
|
||||
}
|
||||
for(const name of ['keydown','beforeinput','paste','submit','pointerdown'])window.addEventListener(name,event=>{
|
||||
if(!visualEnabled||visualApproved)return;
|
||||
const element=event.target;
|
||||
if(name==='submit'||element?.closest?.('input,textarea,[contenteditable="true"],button[type="submit"]')){event.preventDefault();event.stopImmediatePropagation();visualChanged();}
|
||||
},true);
|
||||
// This isolated-world preload exposes no bridge or automation capability to sites.
|
||||
let scheduled=false,last='';
|
||||
function inspect(){scheduled=false;const text=(document.body?.innerText||'').slice(0,250000);if(text===last)return;last=text;ipcRenderer.send('managed:page-content',{text});}
|
||||
function schedule(){if(!scheduled){scheduled=true;setTimeout(inspect,350);}}
|
||||
window.addEventListener('DOMContentLoaded',()=>{schedule();visualChanged();new MutationObserver(()=>{schedule();visualChanged();}).observe(document,{childList:true,subtree:true,characterData:true,attributes:true});});
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
// Check only switch names. Arguments, paths, credentials and agent payloads never enter errors.
|
||||
const BLOCKED_SWITCHES=Object.freeze([
|
||||
'no-sandbox','disable-gpu-sandbox','disable-web-security','allow-running-insecure-content',
|
||||
'remote-debugging-port','remote-debugging-pipe','remote-debugging-socket-name',
|
||||
'load-extension','disable-extensions-except','disable-site-isolation-trials','disable-site-isolation-for-policy',
|
||||
'single-process','in-process-gpu','renderer-cmd-prefix','utility-cmd-prefix','gpu-launcher',
|
||||
'inspect','inspect-brk','inspect-wait','inspect-port','debug','debug-brk','debug-port'
|
||||
]);
|
||||
const failure=()=>{throw new Error('Unsafe browser startup configuration');};
|
||||
function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv){
|
||||
try{
|
||||
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv))return failure();
|
||||
for(const name of BLOCKED_SWITCHES){const present=commandLine.hasSwitch(name);if(typeof present!=='boolean'||present)return failure();}
|
||||
// Electron/Node can consume inspector flags before they reach Chromium's CommandLine.
|
||||
for(const argument of nodeExecArgv){if(typeof argument!=='string'||/^--(?:inspect(?:-brk|-wait|-port)?|debug(?:-brk|-port)?)(?:=|$)/.test(argument))return failure();}
|
||||
// NODE_OPTIONS can activate Node's inspector without appearing in process.execArgv.
|
||||
if(require('node:inspector').url())return failure();
|
||||
return true;
|
||||
}catch{return failure();}
|
||||
}
|
||||
module.exports={assertSecureStartup,BLOCKED_SWITCHES};
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
|
||||
// Native ownership only: renderer tab labels never own or replace a WebContents.
|
||||
function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) {
|
||||
if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry');
|
||||
const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false;
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();};
|
||||
function create({group=''}={}) {
|
||||
if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached');
|
||||
if(typeof group!=='string'||group.length>100)throw TypeError('Invalid tab group');
|
||||
const view=createView(),contents=view?.webContents;
|
||||
if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view');
|
||||
const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}};
|
||||
view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab);
|
||||
if(!activeId)activate(tab.id);return tab;
|
||||
}
|
||||
function activate(id) {
|
||||
const next=requireTab(id);if(id===activeId)return next;
|
||||
const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);}
|
||||
activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next;
|
||||
}
|
||||
function remove(id) {
|
||||
const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.view.webContents.id);
|
||||
if(wasActive){detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&tabs.size)activate(tabs.keys().next().value);
|
||||
}
|
||||
function context(id=activeId,{foreground=true}={}) {
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
}
|
||||
function current(token,{expectedURL=token?.url}={}) {
|
||||
const tab=token&&tabs.get(token.tabId);
|
||||
return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
}
|
||||
function quarantine() {
|
||||
paused=true;activation++;
|
||||
for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();}
|
||||
}
|
||||
function resume(version) {
|
||||
if(!Number.isSafeInteger(version)||version<1||version<policyVersion)throw Error('Invalid tab policy generation');
|
||||
if(version!==policyVersion)for(const tab of tabs.values())invalidate(tab);
|
||||
policyVersion=version;paused=false;
|
||||
}
|
||||
return {create,activate,remove,context,current,quarantine,resume,
|
||||
get:requireTab,active:()=>activeId?requireTab(activeId):null,
|
||||
fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()],
|
||||
navigation:id=>invalidate(requireTab(id)),
|
||||
close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);},
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})};
|
||||
}
|
||||
module.exports={createTabRegistry};
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const MAX_FILE = 8 * 1024 * 1024;
|
||||
const SCRYPT = Object.freeze({ N: 131072, r: 8, p: 1, maxmem: 192 * 1024 * 1024 });
|
||||
const FORMAT = 'safetynet-local-vault-v1';
|
||||
const fail = () => { throw new Error('Vault operation refused'); };
|
||||
const wipe = value => { if (Buffer.isBuffer(value)) value.fill(0); };
|
||||
const encode = value => value.toString('base64');
|
||||
function decode(value, maximum = MAX_FILE) {
|
||||
if (typeof value !== 'string' || value.length > Math.ceil(maximum / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) fail();
|
||||
const result = Buffer.from(value, 'base64');
|
||||
if (result.length > maximum || encode(result) !== value) fail();
|
||||
return result;
|
||||
}
|
||||
function seal(key, bytes, aad) {
|
||||
const nonce = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
cipher.setAAD(Buffer.from(aad));
|
||||
return { nonce: encode(nonce), ciphertext: encode(Buffer.concat([cipher.update(bytes), cipher.final()])), tag: encode(cipher.getAuthTag()) };
|
||||
}
|
||||
function open(key, record, aad) {
|
||||
if (!record || typeof record !== 'object') fail();
|
||||
const nonce = decode(record.nonce, 12), tag = decode(record.tag, 16);
|
||||
if (nonce.length !== 12 || tag.length !== 16) fail();
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAAD(Buffer.from(aad)); decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(decode(record.ciphertext)), decipher.final()]);
|
||||
}
|
||||
function origin(value) {
|
||||
if (typeof value !== 'string' || value.length > 2048) fail();
|
||||
let url; try { url = new URL(value); } catch { fail(); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.origin !== value) fail();
|
||||
return url.origin;
|
||||
}
|
||||
function text(value, maximum) {
|
||||
if (typeof value !== 'string' || value.length > maximum || value.includes('\0')) fail();
|
||||
return value;
|
||||
}
|
||||
function master(value) { return text(value, 1024).length >= 16 ? value : fail(); }
|
||||
function wrappingKey(shared, vaultId) {
|
||||
return Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.from(vaultId), Buffer.from(FORMAT + ':kem-wrap'), 32));
|
||||
}
|
||||
function generatePassword(length = 32) {
|
||||
if (!Number.isInteger(length) || length < 24 || length > 128) fail();
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+';
|
||||
return Array.from({ length }, () => alphabet[crypto.randomInt(alphabet.length)]).join('');
|
||||
}
|
||||
function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
|
||||
if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail();
|
||||
let key = null, entries = [], envelope = null, timer = null;
|
||||
function lock() {
|
||||
clearTimeout(timer); timer = null; wipe(key); key = null;
|
||||
for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; }
|
||||
entries = []; envelope = null;
|
||||
}
|
||||
function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); }
|
||||
function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); }
|
||||
function checkedFile() {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1 || stat.size > MAX_FILE) fail();
|
||||
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
||||
const bytes = Buffer.alloc(MAX_FILE + 1);
|
||||
try {
|
||||
if (fs.fstatSync(fd).size > MAX_FILE) fail();
|
||||
let length = 0, count;
|
||||
while (length < bytes.length && (count = fs.readSync(fd, bytes, length, bytes.length - length, null))) length += count;
|
||||
if (length > MAX_FILE) fail();
|
||||
return JSON.parse(bytes.subarray(0, length).toString('utf8'));
|
||||
} finally { wipe(bytes); fs.closeSync(fd); }
|
||||
}
|
||||
function header(record) {
|
||||
return JSON.stringify({ format: record.format, vaultId: record.vaultId, salt: record.salt, privateKey: record.privateKey, kemCiphertext: record.kemCiphertext });
|
||||
}
|
||||
function persist() {
|
||||
const bytes = Buffer.from(JSON.stringify({ entries }));
|
||||
let temporary;
|
||||
try {
|
||||
const aad = header(envelope);
|
||||
const output = Buffer.from(JSON.stringify({ ...envelope, payload: seal(key, bytes, aad + ':' + JSON.stringify(envelope.wrappedKey)) }));
|
||||
if (output.length > MAX_FILE) fail();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
if (fs.existsSync(filePath) && !fs.lstatSync(filePath).isFile()) fail();
|
||||
temporary = filePath + '.' + crypto.randomBytes(16).toString('hex') + '.tmp';
|
||||
const fd = fs.openSync(temporary, 'wx', 0o600);
|
||||
try { fs.writeFileSync(fd, output); fs.fsyncSync(fd); } finally { fs.closeSync(fd); wipe(output); }
|
||||
fs.renameSync(temporary, filePath); temporary = null;
|
||||
} finally { wipe(bytes); if (temporary) { try { fs.unlinkSync(temporary); } catch {} } }
|
||||
}
|
||||
function create(password) {
|
||||
lock(); let derived, privateDer, protectedBytes, shared, wrap;
|
||||
try {
|
||||
master(password); if (fs.existsSync(filePath)) fail();
|
||||
if (typeof crypto.encapsulate !== 'function' || typeof crypto.decapsulate !== 'function') fail();
|
||||
const salt = crypto.randomBytes(32), vaultId = crypto.randomUUID();
|
||||
derived = crypto.scryptSync(password, salt, 32, SCRYPT);
|
||||
const pair = crypto.generateKeyPairSync('ml-kem-1024');
|
||||
privateDer = pair.privateKey.export({ format: 'der', type: 'pkcs8' });
|
||||
protectedBytes = Buffer.from(JSON.stringify(seal(derived, privateDer, FORMAT + ':private:' + vaultId)));
|
||||
const platformProtected = protect(protectedBytes);
|
||||
if (!Buffer.isBuffer(platformProtected) || !platformProtected.length || platformProtected.length > 65536) fail();
|
||||
const encapsulated = crypto.encapsulate(pair.publicKey); shared = encapsulated.sharedKey;
|
||||
wrap = wrappingKey(shared, vaultId); key = crypto.randomBytes(32);
|
||||
envelope = { format: FORMAT, vaultId, salt: encode(salt), privateKey: encode(platformProtected), kemCiphertext: encode(encapsulated.ciphertext) };
|
||||
envelope.wrappedKey = seal(wrap, key, header(envelope));
|
||||
persist(); touch(); return status();
|
||||
} catch { lock(); fail(); } finally { [derived, privateDer, protectedBytes, shared, wrap].forEach(wipe); }
|
||||
}
|
||||
function unlock(password) {
|
||||
lock(); let derived, protectedBytes, privateDer, shared, wrap, plaintext;
|
||||
try {
|
||||
master(password); const record = checkedFile();
|
||||
if (record.format !== FORMAT || typeof record.vaultId !== 'string' || !/^[a-f0-9-]{36}$/.test(record.vaultId)) fail();
|
||||
const salt = decode(record.salt, 32), ciphertext = decode(record.kemCiphertext, 1568);
|
||||
if (salt.length !== 32 || ciphertext.length !== 1568) fail();
|
||||
protectedBytes = unprotect(decode(record.privateKey, 65536));
|
||||
if (!Buffer.isBuffer(protectedBytes) || protectedBytes.length > 65536) fail();
|
||||
derived = crypto.scryptSync(password, salt, 32, SCRYPT);
|
||||
privateDer = open(derived, JSON.parse(protectedBytes.toString('utf8')), FORMAT + ':private:' + record.vaultId);
|
||||
const privateKey = crypto.createPrivateKey({ key: privateDer, format: 'der', type: 'pkcs8' });
|
||||
if (privateKey.asymmetricKeyType !== 'ml-kem-1024') fail();
|
||||
shared = crypto.decapsulate(privateKey, ciphertext); wrap = wrappingKey(shared, record.vaultId);
|
||||
key = open(wrap, record.wrappedKey, header(record)); if (key.length !== 32) fail();
|
||||
plaintext = open(key, record.payload, header(record) + ':' + JSON.stringify(record.wrappedKey));
|
||||
const decoded = JSON.parse(plaintext.toString('utf8'));
|
||||
if (!Array.isArray(decoded.entries) || decoded.entries.length > 1000) fail();
|
||||
const ids = new Set();
|
||||
for (const entry of decoded.entries) {
|
||||
if (typeof entry.id !== 'string' || !/^[a-f0-9-]{36}$/.test(entry.id) || ids.has(entry.id)) fail(); ids.add(entry.id);
|
||||
origin(entry.origin); text(entry.username, 512); text(entry.password, 4096); text(entry.label, 256);
|
||||
}
|
||||
entries = decoded.entries; envelope = { ...record }; delete envelope.payload; touch(); return status();
|
||||
} catch { lock(); fail(); } finally { [derived, protectedBytes, privateDer, shared, wrap, plaintext].forEach(wipe); }
|
||||
}
|
||||
function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; }
|
||||
function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); }
|
||||
function mutate(action) {
|
||||
requireUnlocked(); const previous = entries.map(entry => ({ ...entry }));
|
||||
try { const result = action(); persist(); return result; } catch { entries = previous; fail(); }
|
||||
}
|
||||
function upsert(entry) {
|
||||
return mutate(() => {
|
||||
if (!entry || typeof entry !== 'object') fail();
|
||||
const current = entry.id ? entries.find(item => item.id === entry.id) : null;
|
||||
if (entry.id && !current) fail(); if (!current && entries.length >= 1000) fail();
|
||||
const updated = { id: current?.id || crypto.randomUUID(), origin: origin(entry.origin), username: text(entry.username, 512), password: text(entry.password, 4096), label: text(entry.label ?? '', 256) };
|
||||
if (!updated.password) fail();
|
||||
if (current) entries[entries.indexOf(current)] = updated; else entries.push(updated);
|
||||
return { id: updated.id };
|
||||
});
|
||||
}
|
||||
function remove(id) { return mutate(() => { const index = entries.findIndex(entry => entry.id === id); if (index < 0) fail(); entries.splice(index, 1); return { deleted: true }; }); }
|
||||
function credential(id, targetOrigin) {
|
||||
requireUnlocked(); const expected = origin(targetOrigin); const entry = entries.find(item => item.id === id);
|
||||
if (!entry || entry.origin !== expected) fail();
|
||||
return { username: entry.username, password: entry.password };
|
||||
}
|
||||
return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword };
|
||||
}
|
||||
module.exports = { createVault, generatePassword };
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const {guardedScreenshot}=require('./page-clipboard-guard.cjs');
|
||||
function bounded(operation,ms){let timer;return Promise.race([operation,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Visual capture timed out')),ms);})]).finally(()=>clearTimeout(timer));}
|
||||
async function capturePNG(contents,rect){
|
||||
if(rect&&(!['x','y','width','height'].every(key=>Number.isInteger(rect[key]))||rect.x<0||rect.y<0||rect.width<16||rect.height<16||rect.width*rect.height>8*1024*1024))throw Error('Visual capture region invalid');
|
||||
// Use Chromium's current render surface: Windows native capture can return a stale frame.
|
||||
// Share the verified internal page-security connection when present; never detach it.
|
||||
// No debugger endpoint or general command interface is exposed to sites or agents.
|
||||
const parameters={format:'png',fromSurface:true,captureBeyondViewport:false,...(rect?{clip:{...rect,scale:1}}:{})};
|
||||
const owned=guardedScreenshot(contents,parameters);
|
||||
const debuggerAPI=contents.debugger;if(!debuggerAPI||!owned&&debuggerAPI.isAttached())throw Error('Visual capture unavailable');
|
||||
let attached=false;
|
||||
try{
|
||||
if(!owned){debuggerAPI.attach('1.3');attached=true;}
|
||||
const result=await bounded(owned||debuggerAPI.sendCommand('Page.captureScreenshot',parameters),3000);
|
||||
if(typeof result?.data!=='string'||result.data.length>14*1024*1024||!/^[A-Za-z0-9+/]+={0,2}$/.test(result.data))throw Error('Visual capture bytes invalid');
|
||||
return Buffer.from(result.data,'base64');
|
||||
}finally{if(attached)debuggerAPI.detach();}
|
||||
}
|
||||
module.exports={capturePNG};
|
||||
@@ -0,0 +1,80 @@
|
||||
'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};
|
||||