Add protected interaction evidence and measured debugging phases

This commit is contained in:
Karl committed 2026-10-13 18:04:02 +02:00
1 parent ea448de2d4
commit 244f01630b
42 files changed
+640 -87

No files matched your search

+24 -3
View File
@@ -53,7 +53,10 @@ function domAction(mode, selector, text, expectedUrl) {
}
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};
const rect=e.getBoundingClientRect(),target={tag:e.tagName.toLowerCase()},role=e.getAttribute('role');
if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;
const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}
e.click(); return {completed:true,target};
}
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');
@@ -103,7 +106,7 @@ 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,history,ai,inspect,audit,appControls={}} = {}) {
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,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);
@@ -142,6 +145,11 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
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)};
if(mode==='click' && result?.completed===true && onInteraction){
const source=result.target,target=source&&['a','button','input'].includes(source.tag)?{tag:source.tag}:undefined;
if(target&&['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(source.role))target.role=source.role;
if(target&&['x','y'].every(k=>Number.isSafeInteger(source[k])&&source[k]>=0&&source[k]<=100000)){target.x=source.x;target.y=source.y;}
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
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','library:organize-plan','library:organize-apply'];
@@ -181,7 +189,20 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
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}));
const version=c.policyVersion??c.policy.version;
const inventory=()=>{const rows=getSessions();if(!Array.isArray(rows)||rows.length>1000)throw Error('Invalid managed inventory');return rows.map(item=>{if(typeof item.title!=='string'||item.title.length>200)throw Error('Invalid managed title');return {id:item.id,title:item.title||'Tab',foreground:item.foreground===true};});};
const rows=inventory(),binding=JSON.stringify(rows);
const current=()=>{const latest=getContext();if(signal?.aborted||!latest?.ready||!latest.policy?.agents?.enabled||!latest.policy.agents.scopes?.includes('sessions')||latest.policy!==c.policy||(latest.policyVersion??latest.policy.version)!==version||(latest.policy.expiresAt&&Date.parse(latest.policy.expiresAt)<=Date.now())||(c.token&&!contextCurrent(c.token))||JSON.stringify(inventory())!==binding)throw Error('Managed session changed during action');};
// One bounded classification avoids reloading local OCR/semantic models
// per tab. A blocked aggregate masks every title; opaque IDs remain usable.
const candidates=rows.filter(row=>!secret(row.title));
let allow=false;
if(candidates.length){
const verdict=await inspect(candidates.map(row=>row.title).join('\n'),{...c,action:'sessions'});current();
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
allow=verdict.decision==='allow';
}
current();return rows.map(row=>({...row,title:allow&&!secret(row.title)?row.title.slice(0,160):'Protected page'}));
},
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'),select:dom('select'),check:dom('check'),
+5 -4
View File
@@ -4,14 +4,15 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
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 sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/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;};
const targetFacts=(e,rect)=>{const target={tag:/^[A-Z][A-Z0-9]{0,31}$/.test(e.tagName)?e.tagName.toLowerCase():'unknown'};const role=e.getAttribute?.('role');if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;if(rect){const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}}return target;};
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;
snapshot.target=targetFacts(field,field.getBoundingClientRect?.());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{
@@ -19,10 +20,10 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
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();
snapshot.target=targetFacts(ancestor,range.getBoundingClientRect?.());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};
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text,target:snapshot.target};
}
const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot;
if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable');
+8 -3
View File
@@ -1,11 +1,12 @@
'use strict';
const {randomUUID}=require('node:crypto');
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,onCompleted,timeoutMs=10000}){
const targetFacts=value=>{if(!value||!['a','button','input','textarea','select','div','span','p','body','unknown'].includes(value.tag))return undefined;const target={tag:value.tag};if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(value.role))target.role=value.role;if(['x','y'].every(k=>Number.isSafeInteger(value[k])&&value[k]>=0&&value[k]<=100000)) {target.x=value.x;target.y=value.y;}return target;};
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 denied=async ruleId=>{await audit('clipboard.block',ruleId,{action:operation,sessionId:c.tabId,outcome:'denied'});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);
@@ -19,7 +20,7 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
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}`);
await audit(`clipboard.${operation}`,undefined,{action:operation,sessionId:c.tabId,outcome:'authorized'});
if(!current(c)||expired)return denied('clipboard-context-changed');
const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
if(operation==='cut'){
@@ -31,6 +32,10 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
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');}
if(onCompleted && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(c.tabId||'')) {
await onCompleted({action:operation,sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,...(targetFacts(snapshot.target)?{target:targetFacts(snapshot.target)}:{}),bytes:Buffer.byteLength(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);})]);}
+5 -5
View File
@@ -10,8 +10,8 @@ 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.request.headers','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(['agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','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','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','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','browser.evidence']);
const operations=new Set(['policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','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','inspect','scroll','click','type','select','check','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']);
@@ -34,7 +34,7 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
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 createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0;
// Parser timing is untrusted metadata: validate the entire batch before creating
// spans, and attach it only to a currently recording browser operation.
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
@@ -42,7 +42,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
}catch{/* Diagnostics must never change inspection results. */}}
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 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++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;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);}}
@@ -63,6 +63,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
}
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,importStages,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})};
return {configure,span,event,importStages,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,lifetimeFailedExports,activeSpans})};
}
module.exports={createDiagnostics,attributes,endpointAllowed};
+20 -1
View File
@@ -19,4 +19,23 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
return validateDownloadEvidence(result)?result:null;
}
module.exports={buildDownloadEvidence,validateDownloadEvidence};
const {InteractionEvidenceSchema}=require('../../packages/core/evidence-payload.cjs');
function validateInteractionEvidence(value){
if(!InteractionEvidenceSchema.safeParse(value).success)return false;
try{const u=new URL(value.pageUrl);return !credentialPatterns.some(p=>p.test(value.pageUrl)||p.test(decodeURIComponent(u.pathname)));}catch{return false;}
}
function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,outcome,bytes,captureSource},policy){
if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||outcome!=='completed')return null;
let page;try{page=new URL(url);if(!['http:','https:'].includes(page.protocol))return null;}catch{return null;}
if(target!==undefined){
if(!target||typeof target!=='object'||Array.isArray(target)||Object.keys(target).some(key=>!['tag','role','x','y'].includes(key))||typeof target.tag!=='string')return null;
// Native adapters expose bounded lowercase tags. Preserve their meaning in
// the canonical evidence representation without accepting arbitrary DOM text.
target={...target,tag:target.tag.toUpperCase()};
}
// Paths can carry identifiers or credentials. Automatic metadata uses origin
// only; detailed evidence remains an explicitly governed separate workflow.
const payload={pageUrl:page.origin+'/',action,sessionId,outcome:'completed',sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})};
return validateInteractionEvidence(payload)?structuredClone(payload):null;
}
module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence};
+20 -10
View File
@@ -1,16 +1,26 @@
'use strict';
const {validateDownloadEvidence}=require('./evidence-capture.cjs');
function collectionAllowed(context){return !!context?.ready&&context.policy?.evidence?.enabled===true&&context.policy.evidence.captureDownloads===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function validatedEvidence(payload){if(!validateDownloadEvidence(payload))throw Error('Invalid protected download evidence');return structuredClone(payload);}
function stripPendingEvidence(queue){return queue.map(({protectedEvidence,...event})=>event);}
const {validateDownloadEvidence,validateInteractionEvidence}=require('./evidence-capture.cjs');
function evidenceType(record){return record?.protectedEvidence?.type??'download';}
function policyAllows(context,type='download'){return ['download','interaction'].includes(type)&&context?.policy?.evidence?.enabled===true&&context.policy.evidence[type==='download'?'captureDownloads':'captureInteractions']===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function collectionAllowed(context){return !!context?.ready&&policyAllows(context,context?.type??'download');}
function validatedEvidence(payload,type='download'){if(!(type==='download'?validateDownloadEvidence(payload):type==='interaction'&&validateInteractionEvidence(payload)))throw Error('Invalid protected evidence');return structuredClone(payload);}
function stripPendingEvidence(queue,context){return queue.map(record=>{if(!record.protectedEvidence)return record;if(context&&policyAllows(context,evidenceType(record)))return record;const {protectedEvidence,...event}=record;return event;});}
async function deliverAuditRecord({record,send,getContext}){
const {protectedEvidence,...event}=record;await send('/api/device/events',event);
if(!protectedEvidence)return {eventDelivered:true,evidenceDelivered:false};
const context=getContext();
if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&(context.policy.evidence?.enabled!==true||context.policy.evidence.captureDownloads!==true||!(Date.parse(context.policy.expiresAt)>Date.now())))return {eventDelivered:true,evidenceDiscarded:true};
if(!context?.ready||!context.policy)throw Error('Evidence delivery paused until managed context is ready');
const payload=validatedEvidence(protectedEvidence.payload);
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type:'download',payload});if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};}
const type=evidenceType(record);
function state(){const context=getContext();if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&!policyAllows(context,type))return 'discard';if(!context?.ready||!context.policy)return 'pause';return 'allow';}
const current=state();if(current==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(current==='pause')throw Error('Evidence delivery paused until managed context is ready');
const payload=validatedEvidence(protectedEvidence.payload,type);
if(type==='interaction'){
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:'agent.action';
if(event.kind!==expectedKind||payload.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(payload.action)||payload.captureSource==='agent-adapter'&&payload.action!=='click')throw Error('Invalid protected evidence source');
}
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type,payload});
// A successful network response must not resurrect collection after a
// concurrent policy revocation or a change of enrolled device.
const after=state();if(after==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(after==='pause')throw Error('Evidence delivery paused until managed context is ready');
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};}
catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;}
}
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord};
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord,evidenceType,policyAllows};
+32 -15
View File
@@ -146,11 +146,14 @@ function managementUrl(value){
return u.origin;
}
const managementRequests=new Map();
const evidenceRequestTypes=new Map(),evidenceSendStorage=new (require('node:async_hooks').AsyncLocalStorage)();
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/evidence'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected evidence identity unavailable');
return require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body});
const identity=device,type=body?.type;if(!['download','interaction'].includes(type))throw Error('Invalid protected evidence type');
const assertCurrent=()=>{if(device!==identity||!require('./evidence-delivery.cjs').policyAllows({policy},type))throw Object.assign(Error('Protected evidence collection disabled'),{status:403});if(!ready)throw Error('Evidence delivery paused until managed context is ready');};
assertCurrent();return evidenceSendStorage.run({type},()=>require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body,assertCurrent,traceOperation:(operation,fn)=>diagnosticSpan('browser.evidence',{operation},fn)}));
}
if(route==='/api/device/policy'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
@@ -159,13 +162,13 @@ async function requestImpl(server,route,body,credential){
const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`;
const correlation=diagnosticCorrelation();if(correlation.traceId&&correlation.spanId)headers.traceparent=`00-${correlation.traceId}-${correlation.spanId}-01`;
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')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);if(evidenceSendStorage.getStore()?.type&&route.startsWith('/api/device/channel/evidence'))evidenceRequestTypes.set(controller,evidenceSendStorage.getStore().type);
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);}
}finally{clearTimeout(timer);managementRequests.delete(controller);evidenceRequestTypes.delete(controller);}
}
function state(){return {startPage:{...(policy?.startPage||{}),news:ready?companyNews:[]},libraryAI:{suggestionsEnabled:ready&&!!policy?.ai.enabled&&!!policy?.library?.aiSuggestionsEnabled&&sectionAllowed(managedSettings(),'library')&&featureAllowed(managedSettings(),'ai'),autoApplyEnabled:ready&&!!policy?.library?.aiAutoApplyEnabled&&policy?.ai?.trustMode==='local',busy:libraryOrganizer?.isBusy()||false},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')),agentIdentityMode:policy?.agents.identityMode||'legacy',agentGrants:ready&&featureAllowed(managedSettings(),'agents')?approvedAgentGrants().map(({id,name,generation,expiresAt})=>({id,name,generation,expiresAt})):[],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}`);}}
@@ -192,7 +195,10 @@ function persistAudit(){
try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
}
function enforceEvidencePolicy(){
if(policy&&(policy.evidence?.enabled!==true||policy.evidence.captureDownloads!==true||!(Date.parse(policy.expiresAt)>Date.now()))){for(const [controller,route]of managementRequests)if(route==='/api/device/evidence'||route.startsWith('/api/device/channel/evidence'))controller.abort();if(auditQueue.some(e=>e.protectedEvidence)){auditQueue=require('./evidence-delivery.cjs').stripPendingEvidence(auditQueue);persistAudit();}}
if(!policy)return;const delivery=require('./evidence-delivery.cjs');
for(const [controller,type]of evidenceRequestTypes)if(!delivery.policyAllows({policy},type))controller.abort();
const next=delivery.stripPendingEvidence(auditQueue,{policy});
if(next.some((record,index)=>record!==auditQueue[index])){auditQueue=next;persistAudit();}
}
async function flushAudit(){
@@ -200,31 +206,35 @@ async function flushAudit(){
try{for(let n=0;n<100&&auditQueue.length;n++){await require('./evidence-delivery.cjs').deliverAuditRecord({record:auditQueue[0],send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})});auditQueue.shift();persistAudit();}}
catch{}finally{flushing=false;}
}
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts){
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
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':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}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;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy})?{protectedEvidence:{deviceId:device.id,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
}
function auditAgent(event){
const action=event.action==='agent.read'?'read':event.action||'agent-request',outcome=event.outcome==='blocked'?'denied':event.outcome;
const helper=require('../../packages/core/agent-audit.cjs'),session=helper.AgentAuditSchema.shape.sessionId.safeParse(event.sessionId);
const agent=helper.agentAuditFacts({action,outcome,...(event.scope?{scope:event.scope}:{}),...(session.success&&session.data?{sessionId:session.data}:{}),...(event.principalId?{principalId:event.principalId,credentialGeneration:event.generation}:{})});
return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,undefined,agent);
return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,event.protectedInteraction,agent);
}
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 next=await diagnosticSpan('browser.policy',{operation:'policy.verify'},()=>assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id));
const {nextBookmarks,nextBookmarkJSON}=await diagnosticSpan('browser.policy',{operation:'policy.bookmarks'},async()=>{
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');
return {nextBookmarks,nextBookmarkJSON};
});
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();await reconcileAgentPolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
}
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy;
@@ -234,10 +244,10 @@ async function applyPolicyImpl(envelope){
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache();
policy=next;if(!next.evidence.enabled||!next.evidence.captureDownloads)enforceEvidencePolicy();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();}}
await reconcileAgentPolicy();
await audit('policy.applied');
policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{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();}}});
await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());
await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied'));
}
async function sync(){
if(!device||syncing)return;syncing=true;
@@ -385,7 +395,10 @@ async function ensureAgentAPI(){
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:auditAgent});
},audit:auditAgent,onInteraction:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
}});
for(const action of ['click','type','select','check']){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,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
if(context.principalId&&['navigate','back','forward','reload','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
@@ -640,7 +653,11 @@ app.whenReady().then(async()=>{
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();}}
onCompleted:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'guarded-clipboard'},policy));
await audit(`clipboard.${metadata.action}`,undefined,undefined,undefined,undefined,payload,undefined,{action:metadata.action,sessionId:metadata.sessionId,outcome:'completed'});
},
audit:async(kind,ruleId,facts)=>{const parsed=require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.safeParse(facts);await audit(kind,ruleId,undefined,undefined,undefined,undefined,undefined,parsed.success?parsed.data:undefined);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};});
+12 -6
View File
@@ -19,19 +19,25 @@ async function protectedPolicyRequest({device,request,save}){
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);}
}
async function protectedEvidenceRequest({device,request,save,payload}){
async function protectedEvidenceRequest({device,request,save,payload,assertCurrent=()=>{},traceOperation=async(_name,fn)=>fn()}){
assertCurrent();
await initializeSigningPin({device,request,save});
assertCurrent();
const crypto=channel.evidenceChannel;
const challenge=await request(device.server,'/api/device/channel/evidence/challenge',{},device.token);
const challenge=await traceOperation('evidence.challenge',()=>request(device.server,'/api/device/channel/evidence/challenge',{},device.token));
assertCurrent();
crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
const context=crypto.prepare(challenge,payload);
let context;
try{
const response=await request(device.server,'/api/device/channel/evidence',context.request,device.token);
await traceOperation('evidence.encrypt',()=>{context=crypto.prepare(challenge,payload);});
assertCurrent();
const response=await traceOperation('evidence.send',()=>request(device.server,'/api/device/channel/evidence',context.request,device.token));
// Status is trusted only inside the authenticated encrypted acknowledgement.
const result=crypto.finish(response,challenge,context);
assertCurrent();
const result=await traceOperation('evidence.ack',()=>crypto.finish(response,challenge,context));assertCurrent();
if(!result||Object.keys(result).sort().join(',')!=='body,status'||!Number.isInteger(result.status)||result.status<200||result.status>599||!result.body||typeof result.body!=='object'||Array.isArray(result.body))throw Error('Protected evidence acknowledgement invalid');
if(result.status>=300){const error=Error('Protected evidence submission refused');error.status=result.status;throw error;}
return result.body;
}finally{context.responseKey.fill(0);}
}finally{context?.responseKey.fill(0);}
}
module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin};