Harden Windows DLP enforcement and Electron runtime packaging

This commit is contained in:
Karl committed 2026-10-13 23:19:22 +02:00
1 parent e7d778aeac
commit 8a253eda89
120 files changed
+3569 -135

No files matched your search

+3 -2
View File
@@ -139,7 +139,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
};
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!=='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');
if (mode==='select' && (typeof args.value!=='string' || args.value.length>16384)) throw Error('Invalid value');
if (mode==='check' && typeof args.checked!=='boolean') throw Error('Invalid checked state');
@@ -200,6 +200,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
for(const [id,item] of historySnapshots)if(item.expires<=Date.now())historySnapshots.delete(id);
while(historySnapshots.size>=32)historySnapshots.delete(historySnapshots.keys().next().value);
const snapshotId=randomUUID();historySnapshots.set(snapshotId,{initial,binding,actorBinding,entries,expires:Date.now()+60000});
// eslint-disable-next-line no-unused-vars -- Strip private native indexes and navigation flags from released metadata.
return {snapshotId,entries:entries.map(({index,navigable,...row})=>row)};
},
'history:go':async(args={}, {signal}={})=>{
@@ -237,7 +238,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
...appAdapters,
...historyAdapters,
...historyAdaptersExtra,
sessions:async(args={}, {signal}={})=>{
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');
const version=c.policyVersion??c.policy.version;
+5 -1
View File
@@ -7,6 +7,7 @@ const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','session
const SCOPES = Object.freeze({ screenshot:'browser:screenshot', sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'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) => {
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
return value;
};
@@ -48,8 +49,9 @@ function validate(body) {
if (['click', 'type','select','check'].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 (!/^[a-zA-Z0-9_#.*[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(action==='select'){if(typeof args.value!=='string'||args.value.length>1024||/\u0000/.test(args.value))throw fail(400,'Invalid option value');clean.value=args.value;}
if(action==='check'){if(typeof args.checked!=='boolean')throw fail(400,'Invalid checked state');clean.checked=args.checked;}
if(action==='scroll'){for(const key of ['x','y']){if(!Number.isSafeInteger(args[key])||Math.abs(args[key])>100000)throw fail(400,'Invalid scroll');clean[key]=args[key];}}
@@ -57,12 +59,14 @@ function validate(body) {
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;}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
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;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
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};}
}
if(action==='library:organize-apply'){clean.proposalId=bounded(args.proposalId,128,'proposal id');if(args.confirm!==undefined&&typeof args.confirm!=='boolean'||args.autoApply!==undefined&&typeof args.autoApply!=='boolean')throw fail(400,'Invalid organization confirmation');if(args.confirm!==undefined)clean.confirm=args.confirm;if(args.autoApply!==undefined)clean.autoApply=args.autoApply;}
+1
View File
@@ -59,6 +59,7 @@ async function persistConnection({filePath,connection,protect,isCurrent}){
guard(target);fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;
if(!isCurrent())throw Error('Agent credential context changed');guard(target);fs.renameSync(temporary,target);
return target;
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
}finally{bytes.fill(0);if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch(error){if(error.code!=='ENOENT')throw error;}}
}
module.exports={activeGrants,authorizeGrant,reconcileGenerationFloor,descriptorPath,persistConnection};
+1
View File
@@ -1,6 +1,7 @@
'use strict';
const {randomUUID}=require('node:crypto');
const credential=/password|passwd|api.?key|access.?token|refresh.?token|client.?secret|authorization|credential|session.?id|-----BEGIN|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{16,}/i;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f]/.test(value))throw Error('Invalid session metadata');let decoded=value;for(let i=0;i<4;i++){if(credential.test(decoded))throw Error('Sensitive session metadata');try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return value;}
function sensitiveURL(value){for(let i=0;i<5;i++){if(/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|credential|session|sid|auth|samlresponse|samlrequest|assertion|ticket|id_token)=/i.test(value)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|[?#]|$)/i.test(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{return true;}}return false;}
function safeTab(row){if(!row||Object.keys(row).some(k=>!['url','title','group'].includes(k)))throw Error('Invalid session metadata');const title=safeText(row.title,200),raw=safeText(row.url,4096),url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||sensitiveURL(raw)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(decodeURIComponent(url.pathname)))throw Error('Unsafe session destination');return {url:url.href,title,...(row.group===undefined?{}:{group:safeText(row.group,80)})};}
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const {randomUUID}=require('node:crypto');
const denied=()=>{const error=Error('Native agent session ownership refused');error.status=403;throw error;};
function identity(input){if(!input||typeof input!=='object'||Object.keys(input).sort().join(',')!=='deviceId,generation,principalId'||typeof input.deviceId!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(input.deviceId)||typeof input.principalId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(input.principalId)||!Number.isSafeInteger(input.generation)||input.generation<1)denied();return Object.freeze({deviceId:input.deviceId.toLowerCase(),principalId:input.principalId,generation:input.generation});}
function createAgentSessionOwnership({createSession,installGuards,isLive,quarantineSession=()=>{},disposeSession=async session=>{await session.closeAllConnections();await session.clearStorageData();},maxSessions=32}={}){
if([createSession,installGuards,isLive,quarantineSession,disposeSession].some(fn=>typeof fn!=='function')||!Number.isInteger(maxSessions)||maxSessions<1||maxSessions>32)throw TypeError('Native ownership callbacks required');
const entries=new Map(),nativeEntries=new WeakMap(),revoked=new Set();let closed=false;
const live=(owner,options)=>{try{return isLive(owner,options)===true;}catch{return false;}};
const key=owner=>JSON.stringify([owner.deviceId,owner.principalId,owner.generation]);
const assertLive=(entry,options={})=>{if(closed||entry.active!==true||revoked.has(entry.key)||(!entry.ready&&!['acquire','install','installed'].includes(options.phase))||!live(entry.owner,{phase:options.phase||'network',url:options.url,background:options.background===true}))denied();};
const invalidate=entry=>{if(!entry.active)return;entry.active=false;entry.epoch++;revoked.add(entry.key);try{if(entry.session)quarantineSession(entry.session,entry.owner);}catch{/* Native release guards are already revoked. */}};
const cleanup=async entry=>{if(entry.cleanup)return entry.cleanup;entry.cleanup=(async()=>{if(entry.session&&!entry.disposed){await disposeSession(entry.session,entry.owner);entry.disposed=true;}if(entries.get(entry.key)===entry)entries.delete(entry.key);})();try{await entry.cleanup;}finally{entry.cleanup=null;}};
async function acquire(input,{signal}={}){const owner=identity(input),ownerKey=key(owner);if(closed||signal?.aborted||revoked.has(ownerKey)||revoked.size>=4096||!live(owner,{phase:'acquire',background:false}))denied();let entry=entries.get(ownerKey);if(!entry){if(entries.size>=maxSessions)denied();entry={owner,key:ownerKey,partition:'safetynet-agent-'+randomUUID(),active:true,ready:false,epoch:1,session:null,disposed:false};entries.set(ownerKey,entry);entry.promise=(async()=>{try{assertLive(entry,{phase:'acquire'});const session=await createSession(entry.partition,{cache:false});if(!session||typeof session!=='object'||nativeEntries.has(session))denied();entry.session=session;nativeEntries.set(session,entry);assertLive(entry,{phase:'install'});await installGuards(session,owner,options=>assertLive(entry,options));assertLive(entry,{phase:'installed'});entry.ready=true;return session;}catch(error){invalidate(entry);try{await cleanup(entry);}catch{}throw error;}})();}const aborted=()=>invalidate(entry);signal?.addEventListener('abort',aborted,{once:true});try{const session=await entry.promise;if(signal?.aborted)denied();assertLive(entry,{phase:'acquire'});if(!entry.ready)denied();return session;}finally{signal?.removeEventListener('abort',aborted);if(!entry.active)try{await cleanup(entry);}catch{}}}
function getOwner(session){const entry=nativeEntries.get(session);if(!entry||!entry.ready)denied();assertLive(entry,{phase:'owner'});return entry.owner;}
function guard(session,options={}){options=Object.freeze({phase:options.phase,url:options.url,background:options.background===true});const entry=nativeEntries.get(session);if(!entry||!entry.ready)denied();assertLive(entry,options);const epoch=entry.epoch;return Object.freeze({owner:entry.owner,assertCurrent:()=>{if(nativeEntries.get(session)!==entry||entry.epoch!==epoch)denied();assertLive(entry,options);}});}
async function revoke(input){const owner=identity(input),ownerKey=key(owner);revoked.add(ownerKey);const entry=entries.get(ownerKey);if(!entry)return;invalidate(entry);try{await entry.promise;}catch{}await cleanup(entry);}
async function close(){closed=true;const all=[...entries.values()];for(const entry of all)invalidate(entry);await Promise.allSettled(all.map(async entry=>{try{await entry.promise;}catch{}await cleanup(entry);}));}
function enumerateEntries(){return Object.freeze([...entries.values()].map(entry=>Object.freeze({owner:entry.owner,session:entry.session,ready:entry.ready,active:entry.active,epoch:entry.epoch,disposed:entry.disposed})));}
function liveSessions(){return Object.freeze([...entries.values()].filter(entry=>entry.active&&entry.ready&&live(entry.owner,{phase:'owner',background:false})).map(entry=>entry.session));}
async function sweep(){const all=[...entries.values()];for(const entry of all)if(!live(entry.owner,{phase:'reconcile',background:false}))invalidate(entry);return Promise.allSettled(all.filter(entry=>!entry.active).map(async entry=>{try{await entry.promise;}catch{}await cleanup(entry);}));}
return {acquire,getOwner,guard,revoke,close,enumerateEntries,liveSessions,sweep};
}
module.exports={createAgentSessionOwnership};
+1
View File
@@ -17,6 +17,7 @@ function validateMutation(input,{policy,ready,settings,destinationAllowed,now=Da
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;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
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');
+29 -2
View File
@@ -7,14 +7,31 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
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;};
// Editor support remains plain text: neither clipboard HTML nor a page's paste
// handler participates in mutation. Bind the exact DOM, focus and selection.
const editorRangeSafe=(e,range)=>{
if(!e?.isConnected||!e.isContentEditable||!visible(e)||sensitive(e)||e.closest('input,textarea,iframe'))return false;
if(!e.contains(range.startContainer)||!e.contains(range.endContainer))return false;
const walker=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let node,count=0;
while((node=walker.nextNode())){if(++count>2048)return false;if(range.intersectsNode(node)&&(!visible(node)||sensitive(node)||node.getAttribute('contenteditable')==='false'||/^(INPUT|TEXTAREA|IFRAME|SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(node.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='';
const snapshot={token,operation,field};let text;
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
if(field.isContentEditable){
const selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null;
if(!range||!editorRangeSafe(field,range))throw Error('Clipboard editor selection unsupported');
if(field.innerHTML.length>limit||field.textContent.length>limit)throw Error('Clipboard target size limit');
snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='editor';snapshot.html=field.innerHTML;snapshot.range=range.cloneRange();
text=operation==='paste'?'':range.toString();
}else{
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
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{
if(operation!=='copy')throw Error('Clipboard edit target unsupported');
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
@@ -33,10 +50,20 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
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 if(snapshot.kind==='editor'){
const e=snapshot.field,selection=getSelection(),range=selection?.rangeCount===1?selection.getRangeAt(0):null,original=snapshot.range;
if(e!==document.activeElement||e.innerHTML!==snapshot.html||!range||range.startContainer!==original.startContainer||range.endContainer!==original.endContainer||range.startOffset!==original.startOffset||range.endOffset!==original.endOffset||!editorRangeSafe(e,range))throw Error('Clipboard editor target changed');
if(mode!=='validate'&&operation!=='copy'){
const value=operation==='paste'?payload:'';
if(typeof value!=='string'||value.length>limit||value.includes('\0'))throw Error('Clipboard payload denied');
range.deleteContents();
if(value){const node=document.createTextNode(value);range.insertNode(node);range.setStartAfter(node);}
range.collapse(true);selection.removeAllRanges();selection.addRange(range);
e.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:operation==='paste'?'insertText':'deleteByCut',data:value}));
}
}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};
+3
View File
@@ -18,11 +18,14 @@ function createCredentialSubmissionBroker({isCurrent,traceOperation=(_operation,
// bounded check covers two whole header-value fragments in either order.
const pairCredential=(l,values)=>stage('credential.match',()=>{if(!Array.isArray(values)||values.length>128)fail();let chars=0;const variants=[];for(let index=0;index<values.length;index++){let value=values[index];if(typeof value!=='string')fail();const seen=new Set();for(let round=0;round<3;round++){if(seen.has(value))break;seen.add(value);chars+=value.length;if(chars>131072)fail();variants.push({index,value});try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}}let work=0;for(const a of variants)for(const b of variants){if(a.index===b.index)continue;if(++work>65536)fail();const length=a.value.length+b.value.length;for(const part of ['username','password'])if(length===l[part].length&&match(l,part,a.value+b.value))return true;}return false;});
const native=(l,details,context)=>stage('credential.native',()=>{if(!Number.isSafeInteger(details.id)||details.id<0||details.method!=='POST'||details.resourceType!=='mainFrame'||details.url!==l.form.actionUrl||details.webContentsId!==context.webContents.id||!details.frame||details.frame!==context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();});
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
function issue({context,formId,username,password,vault,vaultGeneration}){if(closed)fail();expire();if(blocked.size>=1024)fail();if(!context?.policy?.passwordManager?.controlledSubmission?.enabled||!context.token||!context.webContents||!context.mainFrame||context.webContents.mainFrame!==context.mainFrame||!vault||vault.status().locked||vault.accessGeneration()!==vaultGeneration||isCurrent(context,{phase:'issued'})!==true)fail();const forms=context.policy.passwordManager.controlledSubmission.forms;if(!Array.isArray(forms)||forms.length>32)fail();const form=forms.find(f=>f.id===formId);if(!form||!field.test(form.usernameField)||!field.test(form.passwordField)||form.usernameField===form.passwordField)fail();let action,source;try{action=new URL(form.actionUrl);source=new URL(context.sourceURL);}catch{fail();}if(action.protocol!=='https:'||source.protocol!=='https:'||action.origin!==source.origin||action.username||action.password||action.search||action.hash||action.href!==form.actionUrl)fail();if(typeof username!=='string'||typeof password!=='string'||!username||!password||username.length>512||password.length>4096||/[\u0000\uD800-\uDFFF]/u.test(username+password))fail();if(issued.size+pending.size+redirected.size>=32&&!issued.has(context.webContents.id))fail();const old=issued.get(context.webContents.id);if(old)wipe(old);const key=randomBytes(32),lease={context,actor:JSON.stringify(context.actor??null),form:{...form},vault,vaultGeneration,key,username:{digest:digest(key,username),length:username.length},password:{digest:digest(key,password),length:password.length},expires:now()+15000};issued.set(context.webContents.id,lease);return {prepared:true,expiresAt:lease.expires};}
// eslint-disable-next-line no-control-regex, no-useless-assignment -- Explicitly reject control characters in security-sensitive input. Explicitly clear sensitive transient text after use.
function claim(details,bytes,context){expire();if(redirected.has(details.id)){validateRedirect(details,context);return null;}if(blocked.has(details.id)||pending.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return null;const candidate=details.method==='POST'&&details.resourceType==='mainFrame'&&details.url===l.form.actionUrl;let observed=false;try{observed=containsCredential(l,details.url);if(!candidate&&bytes?.length){if(!Buffer.isBuffer(bytes)||bytes.length>65536)fail();observed=observed||containsCredential(l,new TextDecoder('utf-8',{fatal:true}).decode(bytes));}}catch{observed=true;}if(!candidate&&!observed)return null;issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);try{authority(l,context,'request',details);native(l,details,context);if(!Buffer.isBuffer(bytes)||bytes.length<1||bytes.length>65536||!Array.isArray(details.uploadData)||details.uploadData.some(p=>!p.bytes||p.file||p.blobUUID)||hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes))))!==hash(bytes))fail();const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);if(/[\u0000-\u001f\u007f]/.test(text)||text.split('&').length>128)fail();const entries=stage('credential.parse',()=>text.split('&').map(raw=>{const index=raw.indexOf('=');if(index<1||/%(?![0-9a-f]{2})/i.test(raw))fail();let name,value;try{name=decodeURIComponent(raw.slice(0,index).replace(/\+/g,' '));value=decodeURIComponent(raw.slice(index+1).replace(/\+/g,' '));}catch{fail();}if(!field.test(name)||/[\u0000-\u001f\u007f]/.test(value))fail();return {name,value,raw};}));if(new Set(entries.map(e=>e.name)).size!==entries.length)fail();const u=entries.find(e=>e.name===l.form.usernameField),p=entries.find(e=>e.name===l.form.passwordField);if(!u||!p||!stage('credential.match',()=>match(l,'username',u.value)&&match(l,'password',p.value)))fail();const others=entries.filter(e=>e!==u&&e!==p);if(others.some(e=>containsCredential(l,e.name+'\n'+e.value))||containsCredential(l,others.map(e=>e.value).join(''))||containsCredential(l,others.map(e=>e.name).join('')))fail();const residual=stage('credential.residual',()=>Buffer.from([others.map(e=>e.raw).join('&'),others.map(e=>e.name+'\n'+e.value).join('\n'),others.map(e=>e.value).join(''),entries.map(e=>e.name).join('\n')].join('\n')));l.bodyHash=hash(bytes);l.requestId=details.id;l.headersValidated=false;pending.set(details.id,l);return {requestId:details.id,residual,bytes:bytes.length};}catch(error){wipe(l);throw error;}}
function validateHeaders(details,context){expire();const l=pending.get(details.id);if(!l||l.finalized)fail();try{authority(l,context,'headers',details);native(l,details,context);const headers=details.requestHeaders;if(!headers||Object.keys(headers).length>128)fail();const names=Object.keys(headers);if(new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();const get=name=>{const found=names.filter(k=>k.toLowerCase()===name);if(found.length!==1)fail();const value=headers[found[0]];if(typeof value!=='string')fail();return value;};if(!/^application\/x-www-form-urlencoded(?:;\s*charset=utf-8)?$/i.test(get('content-type'))||names.some(k=>k.toLowerCase()==='content-encoding'))fail();if(details.uploadData&&hash(Buffer.concat(details.uploadData.map(p=>Buffer.from(p.bytes||[]))))!==l.bodyHash)fail();if(stage('credential.header-values',()=>containsCredential(l,details.url)||names.some(k=>containsCredential(l,k)||containsCredential(l,headers[k]))||containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names)))fail();l.headersValidated=true;return {requestId:details.id};}catch(error){pending.delete(details.id);wipe(l);throw error;}}
function finalize(id,context,details){expire();const l=pending.get(id);if(!l||l.finalized||!l.headersValidated)fail();try{authority(l,context,'headers',details);native(l,details,context);l.finalized=true;return {authorized:true};}catch(error){pending.delete(id);wipe(l);throw error;}}
function validateRedirect(details,context){expire();const l=redirected.get(details.id);if(!l){if(blocked.has(details.id))fail();return false;}authority(l,context,'redirect',details);if(!['GET','HEAD'].includes(details.method)||details.uploadData?.length||details.resourceType!=='mainFrame'||details.webContentsId!==l.context.webContents.id||!details.frame||details.frame!==l.context.mainFrame||details.initiatorOrigin!==new URL(l.context.sourceURL).origin)fail();if(containsCredential(l,details.url))fail();if(details.requestHeaders){const headers=details.requestHeaders,names=Object.keys(headers);if(names.length>128||new Set(names.map(k=>k.toLowerCase())).size!==names.length)fail();for(const name of names)if(containsCredential(l,name)||containsCredential(l,headers[name]))fail();if(containsCredential(l,names.map(k=>headers[k]).join(''))||containsCredential(l,names.join(''))||pairCredential(l,names.map(k=>headers[k]))||pairCredential(l,names))fail();}return true;}
// eslint-disable-next-line no-useless-assignment -- Explicitly clear sensitive transient text after use.
function guardIssued(details,context){expire();if(pending.has(details.id)||redirected.has(details.id))return false;if(blocked.has(details.id))fail();const l=issued.get(details.webContentsId);if(!l)return false;let matched=false;try{authority(l,context,'watch',details);if(details.webContentsId!==l.context.webContents.id)fail();matched=containsCredential(l,details.url);if(details.requestHeaders){const entries=Object.entries(Object.getOwnPropertyDescriptors(details.requestHeaders));if(entries.length>128)fail();const names=[],values=[];for(const [name,descriptor]of entries){if(!('value'in descriptor)||typeof descriptor.value!=='string')fail();names.push(name);values.push(descriptor.value);if(containsCredential(l,name)||containsCredential(l,descriptor.value))matched=true;}if(new Set(names.map(n=>n.toLowerCase())).size!==names.length)fail();if(containsCredential(l,values.join(''))||containsCredential(l,names.join(''))||pairCredential(l,values)||pairCredential(l,names))matched=true;}if(!matched)return false;}catch(error){issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);throw error;}issued.delete(details.webContentsId);blocked.set(details.id,now()+60000);wipe(l);fail();}
function complete(id){let released=false;for(const map of [pending,redirected]){const l=map.get(id);if(l){released=released||l.finalized===true;map.delete(id);wipe(l);}}blocked.delete(id);return {released};}
function redirect(id){const l=pending.get(id);if(!l)return;pending.delete(id);if(!l.finalized){blocked.set(id,now()+60000);wipe(l);return;}redirected.set(id,l);blocked.delete(id);}
+4 -5
View File
@@ -4,16 +4,14 @@ 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');
let telemetryModules;
function loadTelemetryModules(){return telemetryModules??={...require('@opentelemetry/sdk-trace'),...require('@opentelemetry/sdk-logs'),...require('@opentelemetry/resources'),...require('@opentelemetry/exporter-trace-otlp-http'),...require('@opentelemetry/exporter-logs-otlp-http')};}
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
const names=new Set(['browser.window','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(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','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']);
for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation);
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
for(const operation of ['text.local','text.enterprise'])operations.add(operation);
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
names.add('browser.vault');names.add('browser.credential');
for(const operation of ['vault:prepare-submit','vault-prepare-submit','credential.issue','credential.claim','credential.headers','credential.authorize','credential.redirect','credential.complete','credential.failure','credential.authority','credential.native','credential.parse','credential.match','credential.residual','credential.header-values'])operations.add(operation);
@@ -58,6 +56,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
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 {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules();
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)})]});
+2
View File
@@ -15,7 +15,9 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
if(!facts||!Number.isSafeInteger(facts.size)||facts.size<0||!/^[a-f0-9]{64}$/.test(facts.sha256)||!['allow','block'].includes(decision))return null;
let source;try{source=new URL(url);if(!['http:','https:'].includes(source.protocol))return null;source.username='';source.password='';source.search='';source.hash='';}catch{return null;}
if(source.href.length>4096)return null;
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const result={sourceUrl:source.href,name:safeFilename(name),sha256:facts.sha256,bytes:facts.size,mime:String(mime||'').split(';',1)[0].trim().toLowerCase().replace(/[\x00-\x1f\x7f]/g,'').slice(0,100),decision};
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
return validateDownloadEvidence(result)?result:null;
}
+1
View File
@@ -4,6 +4,7 @@ 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);}
// eslint-disable-next-line no-unused-vars -- Destructure only to strip protected evidence before releasing ordinary event metadata.
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);
+1
View File
@@ -12,6 +12,7 @@ function snapshotHeaders(input,policy){
if(!('value'in descriptor)||!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}$/.test(name)||typeof descriptor.value!=='string')throw Error('headers-uninspectable');
const value=descriptor.value;
if(value.length>limit)throw Error('headers-size-limit');
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(/[\x00-\x08\x0A-\x1F\x7F]|[\uD800-\uDFFF]/u.test(value))throw Error('headers-uninspectable');
bytes+=Buffer.byteLength(name)+Buffer.byteLength(value)+4;
if(bytes>limit)throw Error('headers-size-limit');
@@ -0,0 +1,31 @@
'use strict';
let variants;
// Inspect decoded text independently; concatenating it could dilute a semantic match.
async function inspectTextRepresentations({bytes,policy,inspectLocal,inspectEnterprise,isCurrent,traceOperation=(_name,_facts,fn)=>fn()}){
const block=ruleId=>({decision:'block',ruleId});
let values;
try{const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes);variants??=import('../../packages/core/text-variants.mjs');values=(await variants).inspectionTextVariants(text,policy.dlp.maxBytes);}
catch{return block('normalization-uninspectable');}
const current=()=>{try{return isCurrent()===true;}catch{return false;}};
if(policy.enterprise?.icap?.enabled&&policy.enterprise.icap.scanUploads!==false&&typeof inspectEnterprise!=='function')return block('icap-unavailable');
for(let index=0;index<values.length;index++){
if(!current())return block('inspection-context-changed');
const data=Buffer.from(values[index]);
try{
if(policy.dlp.semantic.enabled||policy.security.yara.enabled){
if(typeof inspectLocal!=='function')return block('inspector-unavailable');
const local=await traceOperation('text.local',{bytes:data.length,index,count:values.length},()=>inspectLocal(data,policy));
if(!current())return block('inspection-context-changed');
if(local?.decision!=='allow')return local?.decision==='block'?local:block('inspection-failed');
if(local.coverage?.complete!==true)return block('inspection-incomplete');
}
if(typeof inspectEnterprise==='function'){
const external=await traceOperation('text.enterprise',{bytes:data.length,index,count:values.length},()=>inspectEnterprise(data,policy));
if(!current())return block('inspection-context-changed');
if(external?.decision!=='allow'||external.coverage?.complete!==true)return external?.decision==='block'?external:block('inspection-incomplete');
}
}finally{data.fill(0);}
}
return current()?{decision:'allow',ruleId:null}:block('inspection-context-changed');
}
module.exports={inspectTextRepresentations};
+1
View File
@@ -46,6 +46,7 @@ function startInspector(root,packaged=false,options={}){
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');
const stages=result._diagnosticStages;delete result._diagnosticStages;if(policy?.diagnostics?.enabled===true&&policy.diagnostics.mode==='debug'&&typeof options.onStages==='function'){try{options.onStages(stages);}catch{}}return result;
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed sanitized error; the original cause may contain sensitive paths or data.
}catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;}
finally{clearTimeout(timer);}
};
+1
View File
@@ -36,6 +36,7 @@ function createLibraryOrganization({capture,assertCurrent,propose,commit,applyTa
await audit('library.ai.applied',`bookmarks-${stored.plan.bookmarks.length}:tabs-${stored.plan.tabs.length}`);
return {completed:true,bookmarkRevision:result.revision,tabCount:stored.plan.tabs.length};
}catch(error){
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed sanitized error; the original cause may contain sensitive paths or data.
if(committed){proposals.delete(input.proposalId);await audit('library.ai.reconcile','central-bookmarks-committed');throw Error('Bookmarks were organized centrally; tabs were unchanged because the browser context changed. Refresh the library.');}
throw error;
}finally{busy=false;changed();}
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const http=require('node:http'),net=require('node:net'),tls=require('node:tls');
const {randomBytes,timingSafeEqual}=require('node:crypto');
function endpoint(value){
const u=new URL(value);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.hash)throw Error('relay-target-refused');
return {url:u.href,scheme:u.protocol,host:u.hostname.replace(/^\[|\]$/g,''),port:Number(u.port||(u.protocol==='https:'?443:80)),path:u.pathname+u.search};
}
function upstream(value){
// Honor the first selected enterprise route. Never downgrade failed or
// unsupported routes to DIRECT, even when a PAC result lists fallback entries.
const first=String(value).split(';')[0].trim();if(first==='DIRECT')return {direct:true};
const m=/^(PROXY|HTTPS) (\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):([0-9]{1,5})$/.exec(first);
if(!m||Number(m[3])<1||Number(m[3])>65535)throw Error('relay-upstream-unsupported');
return {direct:false,tls:m[1]==='HTTPS',host:m[2].replace(/^\[|\]$/g,''),port:Number(m[3])};
}
async function startLocalDlpRelay({authorize,isCurrent,resolveUpstream,resolveAddress,credentialsForProxy=async()=>null,timeoutMs=30000,maxConnections=128,maxBodyBytes=10485760}={}){
if([authorize,isCurrent,resolveUpstream,resolveAddress,credentialsForProxy].some(fn=>typeof fn!=='function')||!Number.isInteger(timeoutMs)||timeoutMs<100||timeoutMs>60000||!Number.isInteger(maxConnections)||maxConnections<1||maxConnections>256)throw TypeError('Relay authority callbacks required');
const username='safetynet-'+randomBytes(16).toString('hex'),password=randomBytes(32).toString('base64url'),authorization='Basic '+Buffer.from(username+':'+password).toString('base64');
const sockets=new Set(),clients=new Set();let closed=false,epoch=1;
const current=context=>{try{return !closed&&context.epoch===epoch&&isCurrent(context.authority)===true;}catch{return false;}};
const bounded=work=>new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('relay-operation-timeout')),timeoutMs);Promise.resolve(work).then(value=>{clearTimeout(timer);resolve(value);},error=>{clearTimeout(timer);reject(error);});});
function check(context){if(!current(context))throw Error('relay-authority-changed');}
function track(socket){sockets.add(socket);socket.on('error',()=>{});socket.once('close',()=>sockets.delete(socket));socket.setTimeout(timeoutMs,()=>socket.destroy());return socket;}
function authenticated(req){const supplied=req.headers['proxy-authorization'];return req.socket.remoteAddress==='127.0.0.1'&&typeof supplied==='string'&&supplied.length===authorization.length&&timingSafeEqual(Buffer.from(supplied),Buffer.from(authorization));}
function headers(req){const h={...req.headers},nominated=String(req.headers.connection||'').split(',').map(value=>value.trim().toLowerCase());for(const key of [...nominated,'proxy-authorization','proxy-authenticate','proxy-connection','connection','keep-alive','upgrade','te','trailer','transfer-encoding'])delete h[key];return h;}
async function route(target,method){const generation=epoch,authority=await bounded(authorize(target.url,{method}));if(!authority)throw Error('relay-authority-refused');const context={authority,epoch:generation};check(context);const selected=upstream(await bounded(resolveUpstream(target.url)));check(context);return {context,selected};}
async function address(host,context){const ip=await bounded(resolveAddress(host,context.authority));check(context);if(typeof ip!=='string'||!net.isIP(ip))throw Error('relay-address-unapproved');return ip;}
async function dial(host,port,secure,context){const ip=await address(host,context);return new Promise((resolve,reject)=>{let socket;try{check(context);socket=track(secure?tls.connect({host:ip,port,servername:host,rejectUnauthorized:true}):net.connect({host:ip,port}));socket.once(secure?'secureConnect':'connect',()=>{try{check(context);resolve(socket);}catch(e){socket.destroy();reject(e);}});socket.once('error',reject);socket.once('close',()=>reject(Error('relay-socket-closed')));}catch(e){reject(e);}});}
async function proxyAuth(selected,context){const credentials=await bounded(credentialsForProxy(selected));check(context);if(!credentials)return null;if(typeof credentials.username!=='string'||typeof credentials.password!=='string'||credentials.username.length>1024||credentials.password.length>1024||credentials.username.includes(':')||/[\r\n]/.test(credentials.username+credentials.password))throw Error('relay-proxy-credentials-invalid');return 'Basic '+Buffer.from(credentials.username+':'+credentials.password).toString('base64');}
async function tunnel(target,selected,context){
const socket=await dial(selected.direct?target.host:selected.host,selected.direct?target.port:selected.port,!selected.direct&&selected.tls,context);
if(selected.direct)return socket;
const auth=await proxyAuth(selected,context),authority=(net.isIP(target.host)===6?'['+target.host+']':target.host)+':'+target.port;
check(context);socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth?'Proxy-Authorization: '+auth+'\r\n':''}\r\n`);
return new Promise((resolve,reject)=>{let bytes=Buffer.alloc(0);const data=chunk=>{bytes=Buffer.concat([bytes,chunk]);if(bytes.length>32768){socket.destroy();reject(Error('relay-proxy-response-limit'));return;}const end=bytes.indexOf('\r\n\r\n');if(end<0)return;socket.removeListener('data',data);try{check(context);if(!/^HTTP\/1\.[01] 200(?: |\r)/.test(bytes.subarray(0,end).toString('latin1')))throw Error('relay-proxy-connect-refused');const tail=bytes.subarray(end+4);if(tail.length)socket.unshift(tail);resolve(socket);}catch(e){socket.destroy();reject(e);}};socket.on('data',data);socket.once('error',reject);socket.once('close',()=>reject(Error('relay-proxy-connect-closed')));socket.once('end',()=>reject(Error('relay-proxy-connect-ended')));});
}
const server=http.createServer(async(req,res)=>{
if(!authenticated(req)){res.writeHead(407,{'Proxy-Authenticate':'Basic realm="S Browser private relay"'});res.end();req.resume();return;}
let remote;try{
const target=endpoint(req.url);if(target.scheme!=='http:')throw Error('relay-plain-scheme-refused');const {context,selected}=await route(target,req.method);
remote=await dial(selected.direct?target.host:selected.host,selected.direct?target.port:selected.port,!selected.direct&&selected.tls,context);check(context);
const outgoingHeaders=headers(req),auth=selected.direct?null:await proxyAuth(selected,context);outgoingHeaders.host=new URL(target.url).host;if(auth)outgoingHeaders['proxy-authorization']=auth;
const agent=new http.Agent({keepAlive:false});agent.createConnection=()=>remote;
const outgoing=http.request({method:req.method,path:selected.direct?target.path:target.url,headers:outgoingHeaders,agent},incoming=>{try{check(context);if(!selected.direct&&incoming.statusCode===407){incoming.destroy();agent.destroy();res.writeHead(502);res.end();return;}res.writeHead(incoming.statusCode,headers(incoming));incoming.on('data',chunk=>{if(!current(context)){incoming.destroy();res.destroy();return;}if(!res.write(chunk))incoming.pause();});res.on('drain',()=>incoming.resume());incoming.on('end',()=>{res.end();agent.destroy();});incoming.on('error',()=>res.destroy());}catch{incoming.destroy();res.destroy();}});
outgoing.on('error',()=>{remote.destroy();if(!res.headersSent)res.writeHead(502);res.end();});let size=0;
req.on('data',chunk=>{size+=chunk.length;if(size>maxBodyBytes||!current(context)){outgoing.destroy();req.destroy();return;}if(!outgoing.write(chunk))req.pause();});outgoing.on('drain',()=>req.resume());req.on('end',()=>outgoing.end());req.on('error',()=>outgoing.destroy());
}catch{remote?.destroy();if(!res.headersSent)res.writeHead(502);res.end();req.resume();}
});
server.on('connect',async(req,client,head)=>{
if(!authenticated(req)){client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="S Browser private relay"\r\nConnection: close\r\n\r\n');return;}
let remote;try{if(!/^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+):[0-9]{1,5}$/.test(req.url))throw Error('relay-connect-target-refused');const target=endpoint('https://'+req.url),{context,selected}=await route(target,'CONNECT');remote=await tunnel(target,selected,context);check(context);if(client.destroyed){remote.destroy();return;}client.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)remote.write(head);
client.on('data',chunk=>{if(!current(context)){client.destroy();remote.destroy();return;}if(!remote.write(chunk))client.pause();});remote.on('drain',()=>client.resume());remote.on('data',chunk=>{if(!current(context)){client.destroy();remote.destroy();return;}if(!client.write(chunk))remote.pause();});client.on('drain',()=>remote.resume());client.once('end',()=>remote.end());remote.once('end',()=>client.end());client.once('close',()=>remote.destroy());remote.once('close',()=>client.destroy());
}catch{remote?.destroy();client.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');}
});
server.on('connection',socket=>{if(closed||socket.remoteAddress!=='127.0.0.1'||clients.size>=maxConnections){socket.destroy();return;}clients.add(socket);track(socket);socket.once('close',()=>clients.delete(socket));});server.on('clientError',(_error,socket)=>socket.destroy());
server.headersTimeout=timeoutMs;server.requestTimeout=timeoutMs;server.keepAliveTimeout=1000;
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
return {proxyRules:'127.0.0.1:'+server.address().port,proxyBypassRules:'<-loopback>',credentials:()=>({username,password}),invalidate(){epoch++;for(const socket of sockets)socket.destroy();},async close(){closed=true;epoch++;for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));},status:()=>({closed,connections:[...clients].filter(socket=>!socket.destroyed).length})};
}
module.exports={startLocalDlpRelay};
+70 -17
View File
@@ -1,7 +1,10 @@
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);
try{assertSecureStartup(app.commandLine);}catch{
console.error('Managed browser startup denied');
process.exit(1);
}
// 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();
@@ -20,7 +23,7 @@ function diagnosticSpan(name,attributes,operation){
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,fileUploadGate,uploadLeaseFatal=false;
let win,view,browserSession,managementSession,localRelay,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
const {createTabRegistry}=require('./tab-registry.cjs');
let tabs,tabsRevision=0;
@@ -39,7 +42,7 @@ 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 {settingsState,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='',libraryOrganizer;
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
@@ -180,7 +183,7 @@ async function pageTheme(tab=activeTab()){
}catch{}
}
function lockVault(){const unlocked=vault&&!vault.status().locked;credentialBroker?.revoke();vault?.lock();if(unlocked)audit('vault.locked');emit();}
function quarantine(reason){ready=false;fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
function quarantine(reason){ready=false;localRelay?.invalidate();fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();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,allowControlledSubmission:!!policy?.passwordManager?.controlledSubmission?.enabled,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(){
@@ -240,8 +243,9 @@ async function applyPolicyImpl(envelope){
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);
localRelay.invalidate();await browserSession.closeAllConnections();
await managementSession.closeAllConnections();await managementSession.setProxy(settings);
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache();
@@ -375,6 +379,7 @@ function requireLibraryAI(){
if(!ready||!policy?.ai.enabled||!policy.library?.aiSuggestionsEnabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('AI library organization is disabled by company policy');
if(!policy.browser.bookmarks.allowUserEdit)throw Error('Bookmark organization is controlled by your administrator');
}
// eslint-disable-next-line no-control-regex -- Strip control characters from security-sensitive metadata.
function nativeLibraryRows(){return tabs.all().filter(tab=>!tab.startPage&&/^https?:\/\//.test(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:String(tab.title).replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,120),url:tab.view.webContents.getURL()}));}
function nativeLibraryEpoch(){return JSON.stringify(tabs.all().map(tab=>[tab.id,tab.navigationId,tab.title,tab.view.webContents.getURL(),tab.group]));}
function assertAgentLibraryDestinations(snapshot){if(agentActorStorage.getStore()?.principalId&&[...(snapshot.tabs||[]),...(snapshot.bookmarks||[])].some(row=>!actorDestinationAllowed(row.url)))throw Error('Agent destination denied');}
@@ -462,9 +467,46 @@ async function dnsGateImpl(url,currentPolicy){
const addresses=resolved.endpoints?.map(v=>v.address)||[];
if(!addresses.length||addresses.some(ip=>securityCore.dnsAddressBlocked(ip,currentPolicy)))throw Error('dns-ip-blocklist');
}
function inspectOutgoingText(bytes,currentPolicy,isCurrent){
return require('./inspect-text-representations.cjs').inspectTextRepresentations({bytes,policy:currentPolicy,isCurrent,
inspectLocal:localInspector?(data,p)=>localInspector.inspect(data,p):undefined,
inspectEnterprise:(data,p)=>scanEnterprise(data,p,'upload'),
traceOperation:(operation,facts,fn)=>diagnosticSpan(operation==='text.local'?'browser.inspect.local':'browser.inspect.enterprise',{operation,bytes:facts.bytes,count:facts.count},fn)});
}
function relayAuthorityCurrent(authority){return ready&&policy===authority.policy&&device?.id===authority.deviceId&&Date.parse(policy.expiresAt)>Date.now();}
async function initializeLocalRelay(){
localRelay=await require('./local-dlp-relay.cjs').startLocalDlpRelay({
authorize:url=>{const authority={policy,deviceId:device?.id};if(!relayAuthorityCurrent(authority)||!core.destinationAllowed(url,policy)||core.inspect(Buffer.from(url),policy).decision!=='allow')throw Error('relay-target-refused');return authority;},
isCurrent:relayAuthorityCurrent,
resolveUpstream:url=>managementSession.resolveProxy(url),
resolveAddress:async(host,authority)=>{const resolved=await managementSession.resolveHost(host,{cacheUsage:'allowed'}),addresses=resolved.endpoints?.map(endpoint=>endpoint.address)||[];if(!relayAuthorityCurrent(authority)||!addresses.length||addresses.some(address=>securityCore.dnsAddressBlocked(address,authority.policy)))throw Error('relay-address-refused');return addresses[0];},
credentialsForProxy:async selected=>require('./proxy-credentials.cjs').deploymentProxyCredentials(selected)
});
// Every managed URL resolves to a non-DIRECT native proxy, including loopback.
// Enterprise DIRECT/PAC/system choices apply only behind this private relay.
await browserSession.setProxy({mode:'fixed_servers',proxyRules:localRelay.proxyRules,proxyBypassRules:localRelay.proxyBypassRules});
}
function responsePolicyHeaders(input){
if(!input||typeof input!=='object'||Array.isArray(input))throw Error('Response policy headers unavailable');
const headers={...input},keys=Object.keys(headers).filter(key=>key.toLowerCase()==='connection-allowlist');
const values=keys.flatMap(key=>Array.isArray(headers[key])?headers[key]:[headers[key]]);
if(values.some(value=>typeof value!=='string'))throw Error('Response policy headers unavailable');
let existing=values.join(',');if(existing.length>65536)throw Error('Response policy headers unavailable');
for(const key of keys)delete headers[key];
if(existing){let quoted=false,escaped=false,depth=0,split=existing.length;
for(let index=0;index<existing.length;index++){const ch=existing[index];if(escaped){escaped=false;continue;}if(quoted&&ch==='\\'){escaped=true;continue;}if(ch==='"'){quoted=!quoted;continue;}if(quoted)continue;if(ch==='(')depth++;if(ch===')'&&--depth<0)throw Error('Response policy headers unavailable');if(ch===','&&depth===0){split=index;break;}}
if(quoted||depth!==0)throw Error('Response policy headers unavailable');
existing=existing.slice(0,split).trim()+'; webrtc=block'+existing.slice(split);
}else existing='("http://*:*/*" "https://*:*/*"); redirects=allow; webrtc=block';
headers['Connection-Allowlist']=[existing];return headers;
}
function hooks(){
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
browserSession.setPermissionCheckHandler(()=>false);
// Native Chromium policy blocks uninspected WebRTC; HTTP(S) retains normal DLP.
browserSession.webRequest.onHeadersReceived({urls:['http://*/*','https://*/*']},(details,callback)=>{
try{callback({cancel:false,responseHeaders:responsePolicyHeaders(details.responseHeaders)});}catch{callback({cancel:true});void audit('dlp.block','response-policy-uninspectable');}
});
const leaseRoot=app.isPackaged?process.resourcesPath:path.join(__dirname,'../..');
fileUploadGate=require('./file-upload-gate.cjs').createFileUploadGate({
maxBytes:context=>context.policy.dlp.maxBytes,
@@ -527,12 +569,18 @@ function hooks(){
if(!core.destinationAllowed(details.url,policy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
if(credentialBroker&&source){try{credentialBroker.guardIssued(details,credentialContext(source));credentialBroker.validateRedirect(details,credentialContext(source));}catch{await audit('vault.submission-denied');message='Login submission blocked by company policy. Contact your administrator.';emit();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(called||!ready||policy!==initialPolicy||!sourceCurrent()){if(!called&&ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','request-source-context-changed',new URL(details.url).hostname);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;}
if(details.resourceType==='webSocket'){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
const address=new URL(details.url),urlBytes=Buffer.from(address.href);
try{
const urlVerdict=core.inspect(urlBytes,initialPolicy);
if(urlVerdict.decision!=='allow'){await audit('dlp.block',urlVerdict.ruleId,address.hostname);finish(true);return;}
const inspected=await inspectOutgoingText(urlBytes,initialPolicy,()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy.expiresAt)>Date.now()&&sourceCurrent());
if(inspected.decision!=='allow'){await audit('dlp.block',inspected.ruleId||'inspection-incomplete',address.hostname);finish(true);return;}
}finally{urlBytes.fill(0);}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,address.hostname);finish(true);return;}
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){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;}
@@ -565,7 +613,7 @@ function hooks(){
const needsLocal=verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled;
if(needsLocal&&!localInspector){claim?.residual.fill(0);audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
const snapshot=claim?Buffer.from(claim.residual):Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;claim?.residual.fill(0);
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'));
try{let result;if(claim||transfer.detectedType==='text'){result=await inspectOutgoingText(snapshot,currentPolicy,()=>!called&&ready&&policy===currentPolicy&&Date.parse(currentPolicy.expiresAt)>Date.now()&&sourceCurrent());}else{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=called||!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;
@@ -748,6 +796,7 @@ app.whenReady().then(async()=>{
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.');}});
ipcMain.on('managed:transport-guard-failure',event=>{if(tabs?.fromContents(event.sender.id)){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');void diagnosticSpan('browser.security',{decision:'block',reason:'transport-guard-unavailable'},()=>audit('navigation.block','transport-guard-unavailable')).catch(()=>{});}});
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,{onStages:stages=>diagnostics.importStages(stages)});
@@ -755,6 +804,8 @@ app.whenReady().then(async()=>{
// 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});
await initializeLocalRelay();
browserSession.registerPreloadScript({type:'frame',id:'managed-transport-deny',filePath:path.join(__dirname,'transport-deny.cjs')});
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);
@@ -767,7 +818,7 @@ app.whenReady().then(async()=>{
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();},
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'&&type!=='text/html'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE|HTML Format)"$/.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;},
onCompleted:async metadata=>{
@@ -921,13 +972,15 @@ app.whenReady().then(async()=>{
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',()=>{fileUploadGate?.dispose();tabs?.close();});
app.on('before-quit',()=>{void localRelay?.close().catch(()=>{});fileUploadGate?.dispose();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)=>{
app.on('login',(event,wc,_details,authInfo,callback)=>{
event.preventDefault();
if(localRelay&&authInfo.isProxy&&authInfo.host==='127.0.0.1'&&authInfo.port===Number(localRelay.proxyRules.split(':').at(-1))){
if(ready&&policy&&Date.parse(policy.expiresAt)>Date.now()&&(!wc||wc.session===browserSession)){const credentials=localRelay.credentials();callback(credentials.username,credentials.password);}else callback();return;
}
// 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();
const credentials=authInfo.isProxy?require('./proxy-credentials.cjs').deploymentProxyCredentials(authInfo):null;
if(credentials)callback(credentials.username,credentials.password);else callback();
});
+8
View File
@@ -0,0 +1,8 @@
'use strict';
function deploymentProxyCredentials(selected,environment=process.env){
const {SAFETYNET_PROXY_USER:user,SAFETYNET_PROXY_PASSWORD:password,SAFETYNET_PROXY_AUTH_HOST:host,SAFETYNET_PROXY_AUTH_PORT:port}=environment;
if(!user||!password||!host||!port||!/^[0-9]{1,5}$/.test(port)||Number(port)<1||Number(port)>65535)return null;
if(typeof selected?.host!=='string'||selected.host.toLowerCase()!==host.toLowerCase()||Number(selected.port)!==Number(port))return null;
return {username:user,password};
}
module.exports={deploymentProxyCredentials};
+1
View File
@@ -3,6 +3,7 @@ const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node
const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const fail=()=>{throw Error('Managed session collection unavailable');};
const secret=s=>/-----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(s);
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
// Preserve benign query/fragment semantics; identifiable authentication material
// and authentication routes are never reusable collection destinations.
+12 -2
View File
@@ -2,18 +2,28 @@
// 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',
'ignore-certificate-errors','ignore-certificate-errors-spki-list','allow-insecure-localhost',
'reduce-security-for-testing','unsafely-treat-insecure-origin-as-secure',
'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){
function assertSecureStartup(commandLine,nodeExecArgv=process.execArgv,applicationArgv=process.argv){
try{
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv))return failure();
if(!commandLine||typeof commandLine.hasSwitch!=='function'||!Array.isArray(nodeExecArgv)||!Array.isArray(applicationArgv))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();}
// User feature overrides can disable native enforcement. Inspect original argv,
// not Electron's internally populated disable-features switch.
for(const argument of applicationArgv){
if(typeof argument!=='string')return failure();
// Chromium trims Unicode whitespace before recognizing Windows switches.
const normalized=argument.replace(/^[\s\p{White_Space}]+|[\s\p{White_Space}]+$/gu,'');
if(/^(?:--?|\/)(?:disable-features|enable-features|disable-blink-features|enable-blink-features|enable-experimental-web-platform-features)(?:=|$)/i.test(normalized))return failure();
}
// NODE_OPTIONS can activate Node's inspector without appearing in process.execArgv.
if(require('node:inspector').url())return failure();
return true;
+14
View File
@@ -0,0 +1,14 @@
'use strict';
// Frame-only defense against unsupported native RTC paths. This exports no bridge
// and is additional to the browser-process connection policy, not message DLP.
function denyUninspectedRTC(){
const SecurityException=DOMException;
function blockedPeerConnection(){throw new SecurityException('Managed WebRTC inspection is unavailable','NotAllowedError');}
Object.freeze(blockedPeerConnection.prototype);Object.freeze(blockedPeerConnection);
for(const name of ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection']){
if(name in globalThis)Object.defineProperty(globalThis,name,{value:blockedPeerConnection,writable:false,configurable:false,enumerable:true});
}
}
if(process.sandboxed!==true||process.contextIsolated!==true){require('electron').ipcRenderer.send('managed:transport-guard-failure');window.stop();throw Error('Managed transport isolation is required');}
try{require('electron').contextBridge.executeInMainWorld({func:denyUninspectedRTC});}
catch{require('electron').ipcRenderer.send('managed:transport-guard-failure');window.stop();throw Error('Managed transport guard could not be installed');}
+1
View File
@@ -12,6 +12,7 @@ function trustedFile(p){
}
function refused(){return Object.assign(new Error('Upload lease unavailable'),{code:'upload-lease-refused'});}
async function acquireUploadLease({paths,maxBytes=LIMIT,pythonPath,helperPath,onLost=()=>{},signal}){
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
if(process.platform!=='win32'||!Array.isArray(paths)||paths.length<1||paths.length>16||!Number.isInteger(maxBytes)||maxBytes<1||maxBytes>LIMIT||typeof onLost!=='function'||![pythonPath,helperPath].every(p=>typeof p==='string'&&path.win32.isAbsolute(p)&&!p.startsWith('\\\\'))||!paths.every(p=>typeof p==='string'&&/^[A-Za-z]:\\[^\x00-\x1f:]*$/.test(p)&&p.length<=240&&path.win32.normalize(p)===p))throw refused();
if(disabled||signal?.aborted||!trustedFile(pythonPath)||!trustedFile(helperPath))throw refused();
const child=spawn(pythonPath,['-I','-B',helperPath],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR},shell:false});
+1 -1
View File
@@ -24,7 +24,7 @@ function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisua
if(next.policy!==c.policy||next.actor.agentId!==c.actor.agentId||next.actor.generation!==c.actor.generation||!contextCurrent(c.token)||next.sessionId!==c.sessionId||next.webContents!==c.webContents)refused();
return next;
}
function unlocked(c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
function unlocked(_c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
function currentVault(c,args,signal,vault){check(c,args,signal);if(getVault()!==vault||vault.status().locked)refused();}
const status=async(args={}, {signal}={})=>{
const c=snapshot(args,signal,'vault:status'),s=getVault().status();check(c,args,signal);
+32 -5
View File
@@ -3,6 +3,8 @@ import {inspectForAI,allowsConfidentialLocal} from '../../packages/core/policy.m
import {inspector} from './inspection.mjs';
import enterpriseInspection from '../browser/enterprise-inspection.cjs';
import {traceOperation} from './telemetry.mjs';
import {isDeepStrictEqual} from 'node:util';
import {inspectionTextVariants} from '../../packages/core/text-variants.mjs';
function rejected(status,message,ruleId){return Object.assign(Error(message),{status,ruleId});}
export function approvedAIEndpoint(policy,config){
@@ -12,30 +14,55 @@ export function approvedAIEndpoint(policy,config){
if(endpoint.username||endpoint.password||endpoint.search||endpoint.hash||(local?!(['127.0.0.1','[::1]'].includes(endpoint.hostname)&&['http:','https:'].includes(endpoint.protocol)):endpoint.protocol!=='https:'))throw rejected(403,'AI endpoint trust policy rejected');
return endpoint;
}
export async function runApprovedAI({store,config,deviceId,policy,prompt}){
export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}){
return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{
const controller=new AbortController();
const cancel=()=>controller.abort();
signal?.addEventListener('abort',cancel,{once:true});
if(signal?.aborted)cancel();
function assertCurrent(){
const active=store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(deviceId);
const policyChanged=active&&!isDeepStrictEqual(store.effectivePolicy(deviceId),policy);
if(controller.signal.aborted||!active||Date.parse(policy.expiresAt)<=Date.now()||policyChanged){
controller.abort();throw rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed');
}
}
const deadline=setTimeout(()=>controller.abort(),60000);
const monitor=setInterval(()=>{try{assertCurrent();}catch{/* Abort pending provider work; release checks report the fixed failure. */}},100);
monitor.unref();deadline.unref();
try{
assertCurrent();
z.string().min(1).max(100000).parse(prompt);
if(Date.parse(policy.expiresAt)<=Date.now())throw rejected(403,'Policy expired');
const endpoint=approvedAIEndpoint(policy,config);
async function scan(text){
let verdict=inspectForAI(Buffer.from(text),policy);
if(verdict.decision!=='allow')return verdict;
let values;try{values=inspectionTextVariants(text,policy.dlp.maxBytes);}catch{throw rejected(403,'Content normalization could not be completed','normalization-limit');}
for(const value of values){
assertCurrent();
if(verdict.decision==='allow'&&(policy.dlp.semantic.enabled||policy.security.yara.enabled)){
const parsed=await inspector(config,'/inspect',{data:Buffer.from(text).toString('base64'),policy});
const parsed=await inspector(config,'/inspect',{data:Buffer.from(value).toString('base64'),policy});
assertCurrent();
if(parsed.decision==='allow'&&parsed.coverage?.complete!==true)return {decision:'block',ruleId:'incomplete-inspection'};
if(parsed.decision==='block')verdict=allowsConfidentialLocal(parsed,policy)?{...parsed,decision:'allow',classification:'local-only'}:parsed;
}
if(verdict.decision==='allow'){const external=await enterpriseInspection.scanEnterprise(Buffer.from(text),policy,'ai');if(external.decision!=='allow')verdict=external;}
if(verdict.decision==='allow'){const external=await enterpriseInspection.scanEnterprise(Buffer.from(value),policy,'ai');assertCurrent();if(external.decision!=='allow')verdict=external;}
if(verdict.decision!=='allow')return verdict;
}
return verdict;
}
const input=await traceOperation('ai.inspect.input',{bytes:Buffer.byteLength(prompt),policyVersion:policy.version},()=>scan(prompt));store.event(deviceId,'ai.decision',{...input,policyVersion:policy.version});
if(input.decision==='block')throw rejected(403,'Company policy blocked this prompt',input.ruleId);
const response=await traceOperation('ai.provider.request',{bytes:Buffer.byteLength(prompt),policyVersion:policy.version},()=>fetch(endpoint,{method:'POST',redirect:'error',headers:{'content-type':'application/json',...(config.aiKey?{authorization:`Bearer ${config.aiKey}`}:{})},body:JSON.stringify({model:policy.ai.model,messages:[{role:'user',content:prompt}],stream:false}),signal:AbortSignal.timeout(60000)}));
const response=await traceOperation('ai.provider.request',{bytes:Buffer.byteLength(prompt),policyVersion:policy.version},()=>{assertCurrent();return fetch(endpoint,{method:'POST',redirect:'error',headers:{'content-type':'application/json',...(config.aiKey?{authorization:`Bearer ${config.aiKey}`}:{})},body:JSON.stringify({model:policy.ai.model,messages:[{role:'user',content:prompt}],stream:false}),signal:controller.signal});});
assertCurrent();
if(!response.ok)throw rejected(502,'AI provider request failed');
const text=await traceOperation('ai.provider.read',{policyVersion:policy.version},async()=>{const chunks=[];let size=0;for await(const chunk of response.body){size+=chunk.length;if(size>2*1024*1024){await response.body.cancel().catch(()=>{});throw Error('AI response too large');}chunks.push(chunk);}return Buffer.concat(chunks).toString('utf8');});
const answer=await traceOperation('ai.provider.decode',{bytes:Buffer.byteLength(text),policyVersion:policy.version},async()=>{const result=JSON.parse(text);return z.string().max(100000).parse(policy.ai.provider==='ollama'?result.message?.content:result.choices?.[0]?.message?.content);});
const output=await traceOperation('ai.inspect.output',{bytes:Buffer.byteLength(answer),policyVersion:policy.version},()=>scan(answer));store.event(deviceId,'ai.output-decision',{...output,policyVersion:policy.version});
if(output.decision==='block'){store.event(deviceId,'ai.output-block',{ruleId:output.ruleId,policyVersion:policy.version});throw rejected(403,'AI response blocked by company policy',output.ruleId);}
return answer;
assertCurrent();return answer;
}catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw e;}
finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();}
});
}
+6 -17
View File
@@ -12,7 +12,7 @@ import {runApprovedAI} from './ai-gateway.mjs';
import { z } from 'zod';
import { randomUUID,timingSafeEqual } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { PolicySchema, inspect, inspectForAI, allowsConfidentialLocal } from '../../packages/core/policy.mjs';
import { PolicySchema, inspect } from '../../packages/core/policy.mjs';
import { digest, token } from './store.mjs';
import {policyPublisher} from './policy-review.mjs';
import {installPolicyHistory} from './policy-history.mjs';
@@ -20,7 +20,6 @@ import {installUserProfiles} from './user-profiles.mjs';
import {installPolicyGroups} from './policy-groups.mjs';
import { adminMiddleware, authRoutes,cookie } from './auth.mjs';
import {bootstrapAllowed,normalizePrincipal} from './admin-access.mjs';
import { tracer } from './telemetry.mjs';
import { toECS,toCEF } from '../../packages/core/siem.mjs';
import {installSIEMRoutes} from './siem.mjs';
import {createCompanyNewsReader} from './company-news.mjs';
@@ -123,9 +122,12 @@ export function createApp(store,config) {
});
app.get('/api/device/start-page/news',device,async(req,res)=>{try{const p=store.effectivePolicy(req.device.id);if(Date.parse(p.expiresAt)<=Date.now())return res.status(403).json({error:'Policy expired'});const news=await readCompanyNews(p);res.json({policyVersion:p.version,news});}catch{res.status(502).json({error:'Company news unavailable'});}});
app.post('/api/device/ai',device,async(req,res,next)=>{
const controller=new AbortController(),cancel=()=>{if(!res.writableFinished)controller.abort();};
req.once('aborted',cancel);res.once('close',cancel);
try{const b=z.object({prompt:z.string().min(1).max(100000)}).strict().parse(req.body),p=store.effectivePolicy(req.device.id);
const answer=await runApprovedAI({store,config,deviceId:req.device.id,policy:p,prompt:b.prompt});res.json({answer});
const answer=await runApprovedAI({store,config,deviceId:req.device.id,policy:p,prompt:b.prompt,signal:controller.signal});if(!controller.signal.aborted)res.json({answer});
}catch(e){if([403,409,502].includes(e.status))return res.status(e.status).json({error:e.message,...(e.ruleId?{ruleId:e.ruleId}:{})});next(e);}
finally{req.removeListener('aborted',cancel);res.removeListener('close',cancel);}
});
app.use('/api/admin',admin);
installSIEMRoutes(app,store,config);
@@ -154,7 +156,7 @@ export function createApp(store,config) {
catch(error){store.db.exec('ROLLBACK');throw error;}
res.json({ok:true,revoked:1});
});
app.post('/api/admin/integrations/icap/probe',async(req,res,next)=>{
app.post('/api/admin/integrations/icap/probe',async(req,res,_next)=>{
try{const settings=EnterpriseSchema.parse({icap:req.body}).icap;const result=await icapClient.probeIcap(settings,enterpriseInspection.deploymentTLS());store.event(null,'integration.tested',{actor:req.principal.sub,integration:'icap',ready:result.ready});res.json({...result,scope:'management-server',check:'options-only',contentSent:false});}
catch{res.status(400).json({error:'ICAP configuration or deployment certificates are unavailable'});}
});
@@ -271,16 +273,3 @@ export function createApp(store,config) {
});
return app;
}
async function readLimited(response,maxBytes) {
const chunks=[];let size=0;
for await(const chunk of response.body){size+=chunk.length;if(size>maxBytes){await response.body.cancel().catch(()=>{});throw Error('AI response too large');}chunks.push(chunk);}
return Buffer.concat(chunks).toString('utf8');
}
+1
View File
@@ -29,6 +29,7 @@ function download(url,{address,family,signal}){
});req.on('timeout',()=>req.destroy(Error('News request timeout')));req.on('error',reject);
});
}
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const plain=value=>value.replace(/<[^>]*>/g,' ').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim();
export function parseCompanyNews(bytes){
if(bytes.length>MAX_BYTES)throw Error('News feed too large');
+1
View File
@@ -7,6 +7,7 @@ import {installEvidenceChannel} from './evidence-channel.mjs';
import {assertPrivateEvidenceKey} from './evidence-key.mjs';
import {InteractionEvidenceSchema} from '../../packages/core/evidence.mjs';
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden');
const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment');
const digest=z.string().regex(/^[a-f0-9]{64}$/);
+2
View File
@@ -2,6 +2,7 @@ import {z} from 'zod';
import {randomUUID} from 'node:crypto';
const statuses=['open','investigating','resolved','dismissed'],severities=['low','medium','high','critical'];
const positive=z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER);
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
const note=z.string().trim().min(1).max(1000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters are forbidden');
const allowedMetadata=['decision','ruleId','policyVersion','version','host','level','sourceEventId','contentType','classification','coverage'];
function safeEvent(row){if(!row)return null;const input=JSON.parse(row.metadata),metadata={};for(const key of allowedMetadata){const v=input[key];if(typeof v==='string'&&v.length<=256)metadata[key]=v;else if(typeof v==='number'&&Number.isSafeInteger(v))metadata[key]=v;}return {id:row.id,time:row.time,kind:row.kind,deviceId:row.device_id,metadata};}
@@ -17,6 +18,7 @@ export function installIncidents(app,store){
app.get('/api/admin/incidents',(req,res)=>{const q=z.object({status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),before:positive.optional(),limit:z.coerce.number().int().min(1).max(100).default(50)}).strict().parse(req.query),conditions=[],values=[];for(const key of ['status','severity'])if(q[key]){conditions.push(`${key}=?`);values.push(q[key]);}if(q.before){conditions.push('seq<?');values.push(q.before);}const rows=db.prepare(`SELECT * FROM incident_cases ${conditions.length?'WHERE '+conditions.join(' AND '):''} ORDER BY seq DESC LIMIT ?`).all(...values,q.limit+1),more=rows.length>q.limit;rows.length=Math.min(rows.length,q.limit);res.json({incidents:rows.map(view),nextBefore:more?rows.at(-1).seq:null});});
app.get('/api/admin/incidents/:id',(req,res)=>{const row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});res.json({...view(row),history:db.prepare('SELECT revision,time,actor,status,severity,assignee,comment FROM incident_changes WHERE case_id=? ORDER BY revision').all(row.id)});});
app.post('/api/admin/incidents',(req,res)=>{const b=z.object({eventId:positive,severity:z.enum(severities).default('medium')}).strict().parse(req.body),source=db.prepare('SELECT * FROM events WHERE id=?').get(b.eventId);if(!source)return res.status(404).json({error:'Security event not found'});if(source.kind.startsWith('incident.'))return res.status(400).json({error:'Incident audit events cannot create incidents'});if(db.prepare('SELECT id FROM incident_cases WHERE event_id=?').get(b.eventId))return res.status(409).json({error:'Event already linked to an incident'});const time=new Date().toISOString(),row={id:randomUUID(),event_id:b.eventId,revision:1,status:'open',severity:b.severity,assignee:null,created_at:time,updated_at:time};transaction(()=>{db.prepare('INSERT INTO incident_cases(id,event_id,revision,status,severity,assignee,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)').run(row.id,row.event_id,1,row.status,row.severity,null,time,time);append(row,req.principal.sub);store.event(source.device_id,'incident.created',{actor:req.principal.sub,incidentId:row.id,sourceEventId:b.eventId,revision:1,status:'open',severity:row.severity});});res.status(201).json(view(row));});
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
app.patch('/api/admin/incidents/:id',(req,res)=>{const b=z.object({expectedRevision:positive,status:z.enum(statuses).optional(),severity:z.enum(severities).optional(),assignee:z.string().trim().min(1).max(160).refine(v=>!/[\u0000-\u001f\u007f]/.test(v),'Control characters are forbidden').nullable().optional(),comment:note.optional()}).strict().parse(req.body),row=find(req.params.id);if(!row)return res.status(404).json({error:'Incident not found'});if(row.revision!==b.expectedRevision)return res.status(409).json({error:'Incident revision conflict',currentRevision:row.revision});if(row.revision>=1000)return res.status(409).json({error:'Incident history limit reached'});const next={...row,...Object.fromEntries(['status','severity','assignee'].filter(k=>Object.hasOwn(b,k)).map(k=>[k,b[k]])),revision:row.revision+1,updated_at:new Date().toISOString()};if(!b.comment&&['status','severity','assignee'].every(k=>next[k]===row[k]))return res.status(400).json({error:'No incident change supplied'});transaction(()=>{const change=db.prepare('UPDATE incident_cases SET revision=?,status=?,severity=?,assignee=?,updated_at=? WHERE id=? AND revision=?').run(next.revision,next.status,next.severity,next.assignee,next.updated_at,row.id,b.expectedRevision);if(change.changes!==1)throw Object.assign(Error('Incident revision conflict'),{status:409});append(next,req.principal.sub,b.comment);store.event(null,'incident.updated',{actor:req.principal.sub,incidentId:row.id,sourceEventId:row.event_id,revision:next.revision,status:next.status,severity:next.severity,...(b.comment?{commentLength:b.comment.length}:{})});});res.json(view(next));});
}
+3 -3
View File
@@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],
const managedSections=['appearance','library','ai','passwords','documentScan','agents'];
const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit'];
const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','network:background'];
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
@@ -58,7 +58,7 @@ function fill(p){
const date=new Date(p.expiresAt);$('expiry').value=new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16);
$('downloads').checked=p.browser.allowDownloads;$('devtools').checked=p.browser.allowDevTools;$('websockets').checked=p.browser.allowWebSockets;
$('downloads').checked=p.browser.allowDownloads;$('devtools').checked=p.browser.allowDevTools;$('websockets').checked=p.browser.allowWebSockets;$('agent-background-workers').checked=p.browser.allowAgentBackgroundWorkers===true;
$('ai-enabled').checked=p.ai.enabled;$('ai-model').value=p.ai.model;$('max-bytes').value=p.dlp.maxBytes;$('semantic-enabled').checked=p.dlp.semantic.enabled;renderRules();renderSemantic();
@@ -74,7 +74,7 @@ function draft(){
p.brand={...p.brand,name:$('brand-name').value,color:$('brand-color').value};
p.browser={homeUrl:$('home-url').value,allowedHosts:lines('allowed-hosts'),blockedHosts:lines('blocked-hosts'),allowDownloads:$('downloads').checked,allowDevTools:$('devtools').checked,allowWebSockets:$('websockets').checked};
p.browser={homeUrl:$('home-url').value,allowedHosts:lines('allowed-hosts'),blockedHosts:lines('blocked-hosts'),allowDownloads:$('downloads').checked,allowDevTools:$('devtools').checked,allowWebSockets:$('websockets').checked,allowAgentBackgroundWorkers:$('agent-background-workers').checked};
p.browser.settings={visible:$('settings-visible').checked,hiddenSections:managedSections.filter(section=>$(`settings-hide-${section}`).checked),lockedSections:managedSections.filter(section=>$(`settings-lock-${section}`).checked),appearance:{mode:$('settings-theme').value,accent:$('settings-accent').value,tabMode:$('settings-tabs').value}};
+1 -1
View File
@@ -13,7 +13,7 @@ function renderAgentGrants(){
const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();});
const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();});
const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();});
const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Preparing a controlled login POST requires its separate capability, approved form policy and human unlock; preparation does not submit the form. Agents cannot unlock the vault or export passwords. Visual export requires its separate screenshot capability in the central baseline and this grant, and is limited to an inspected foreground webpage viewport, never the desktop or browser interface. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Preparing a controlled login POST requires its separate capability, approved form policy and human unlock; preparation does not submit the form. Agents cannot unlock the vault or export passwords. Visual export requires its separate screenshot capability in the central baseline and this grant, and is limited to an inspected foreground webpage viewport, never the desktop or browser interface. Background network permission is not a callable action; it requires the central development worker gate, an immutable individual-owned partition and this grant. Human/shared/legacy workers remain unsupported. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});}
const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke);
if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);}
@@ -0,0 +1,25 @@
/* Typed convenience editor; backend schema and independent review remain authority. */
(function(root){
function validateCredentialRuleDraft(rules){
if(!Array.isArray(rules)||rules.length>32)throw Error('Use at most 32 approved login POST rules');
const ids=new Set(),targets=new Set();return rules.map((rule,index)=>{
const label='Login rule '+(index+1);if(!rule||typeof rule!=='object'||Array.isArray(rule)||Object.keys(rule).sort().join(',')!=='actionUrl,id,passwordField,usernameField')throw Error(label+' must contain only rule ID, action URL and credential field names');
if(typeof rule.id!=='string'||rule.id.length>80||!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(rule.id)||ids.has(rule.id))throw Error(label+' needs a unique valid identifier');ids.add(rule.id);
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
let url;try{url=new URL(rule.actionUrl);}catch{throw Error(label+' needs an exact HTTPS action URL');}if(typeof rule.actionUrl!=='string'||rule.actionUrl.length>4096||url.protocol!=='https:'||url.username||url.password||url.search||url.hash||rule.actionUrl.includes('?')||rule.actionUrl.includes('#')||url.href!==rule.actionUrl||/[\u0000-\u0020\u007f]/.test(rule.actionUrl))throw Error(label+' needs a canonical HTTPS URL without credentials, query or fragment');
for(const key of ['usernameField','passwordField'])if(typeof rule[key]!=='string'||!/^[\x21-\x7e]{1,128}$/.test(rule[key]))throw Error(label+' needs printable ASCII field names (maximum 128 characters)');if(rule.usernameField===rule.passwordField)throw Error(label+' needs different username and password fields');const target=JSON.stringify([rule.actionUrl,rule.usernameField,rule.passwordField]);if(targets.has(target))throw Error(label+' duplicates an approved target/field signature');targets.add(target);return {...rule};
});
}
if(typeof module==='object'&&module.exports)module.exports={validateCredentialRuleDraft};
root.validateCredentialRuleDraft=validateCredentialRuleDraft;if(typeof document==='undefined')return;
let rules=[],editable=false,invalidImport=false;
const status=(message,error=false)=>{const box=$('vault-rule-status');box.textContent=message;box.className=error?'error':'muted';};
const changed=()=>{$('vault-submit-forms').value=JSON.stringify(rules,null,2);try{validateCredentialRuleDraft(rules);status('Login rule changes remain in this reviewed policy draft.');}catch(error){status(error.message,true);}try{updateDraftStatus();}catch{}};
function render(){const host=$('vault-rule-editor');host.replaceChildren();$('vault-rule-add').disabled=!editable||invalidImport||rules.length>=32;for(const [index,rule]of rules.entries()){const box=document.createElement('fieldset'),legend=document.createElement('legend');legend.textContent='Login rule '+(index+1);box.append(legend);const grid=document.createElement('div');grid.className='grid';for(const [key,label,max]of [['id','Rule identifier',80],['actionUrl','Exact HTTPS POST action URL',4096],['usernameField','Username field name',128],['passwordField','Password field name',128]]){const item=document.createElement('label');item.textContent=label;const field=document.createElement('input');field.value=rule[key]||'';field.maxLength=max;field.disabled=!editable||invalidImport;field.autocomplete='off';field.spellcheck=false;field.dataset.credentialRuleField=key;field.addEventListener('input',()=>{if(!editable||invalidImport)return;rule[key]=field.value;changed();});item.append(field);grid.append(item);}box.append(grid);const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove login rule';remove.disabled=!editable||invalidImport;remove.addEventListener('click',()=>{if(!editable||invalidImport)return;rules.splice(index,1);render();changed();});box.append(remove);host.append(box);}}
function importRules(){try{const value=JSON.parse($('vault-submit-forms').value||'[]');if(!Array.isArray(value)||value.length>32||value.some(rule=>!rule||typeof rule!=='object'||Array.isArray(rule)||Object.keys(rule).sort().join(',')!=='actionUrl,id,passwordField,usernameField'))throw Error('Advanced login rules must be an array of at most 32 metadata-only rules');invalidImport=false;rules=structuredClone(value);render();try{validateCredentialRuleDraft(rules);status('Only login target metadata is edited here; never enter credential values.');}catch(error){status(error.message,true);}}catch{invalidImport=true;render();status('Advanced login rule JSON is invalid; it has not been replaced or discarded. Fix it before using typed rows.',true);}}
const priorFill=fill;fill=function(p){priorFill(p);importRules();};
const priorDraft=draft;draft=function(){const p=priorDraft();p.passwordManager.controlledSubmission.forms=validateCredentialRuleDraft(p.passwordManager.controlledSubmission.forms);if(p.passwordManager.controlledSubmission.enabled&&!p.passwordManager.controlledSubmission.forms.length)throw Error('Enablement requires at least one approved login POST target');return p;};
const priorPermissions=applyOperatorPermissions;applyOperatorPermissions=function(principal){priorPermissions(principal);editable=principal?.permissions?.includes(independentApprovalRequired?'policy.prepare':'policy.write')===true;render();};
$('vault-rule-add').addEventListener('click',()=>{if(!editable||invalidImport||rules.length>=32)return;rules.push({id:'',actionUrl:'',usernameField:'username',passwordField:'password'});render();changed();});$('vault-submit-forms').addEventListener('change',()=>{importRules();try{updateDraftStatus();}catch{}});
if(policy)importRules();if(currentPrincipal)applyOperatorPermissions(currentPrincipal);
})(typeof globalThis==='object'?globalThis:this);
+4 -4
View File
@@ -9,13 +9,13 @@
<section id="policy" class="card"><div class="section-head"><div><p class="eyebrow">CENTRAL CONFIGURATION</p><h2>One policy. Every managed browser.</h2></div><span class="pill" id="identity"></span></div>
<div class="grid"><label>Brand name<input id="brand-name" maxlength="40"></label><label>Brand accent<input id="brand-color" type="color"></label><label class="wide">Home URL<input id="home-url" type="url"></label><label>Allowed hosts <small>Empty allows all HTTP(S) destinations</small><textarea id="allowed-hosts" placeholder="example.com&#10;*.company.com"></textarea></label><label>Blocked hosts <small>One host per line</small><textarea id="blocked-hosts" placeholder="unapproved-ai.example"></textarea></label><label>Proxy mode<select id="proxy-mode"><option value="system">System proxy</option><option value="direct">Direct connection</option><option value="fixed_servers">Fixed proxy servers</option><option value="pac_script">HTTPS PAC script</option></select></label><label>Proxy rules / PAC URL<input id="proxy-value" placeholder="http=proxy.company.com:8080;https=proxy.company.com:8080"></label><label>Proxy bypass list<input id="proxy-bypass" placeholder="*.company.com"></label><label>Policy expiry<input id="expiry" type="datetime-local"></label><label>AI model<input id="ai-model" placeholder="Must match server-approved model"></label><label>Maximum inspected bytes<input id="max-bytes" type="number" min="1024" max="10485760"></label></div>
<div class="checks"><label><input id="downloads" type="checkbox"> Allow downloads</label><label><input id="devtools" type="checkbox"> Allow developer tools</label><label><input id="websockets" type="checkbox"> Allow WebSockets (messages not inspected)</label><label><input id="ai-enabled" type="checkbox"> Enable approved AI gateway</label></div><button id="publish">Publish signed policy</button><p class="muted">Devices refresh every 15 seconds. Changes and actor identity are recorded in the audit log.</p></section>
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.</p><button id="groups-refresh" classLine truncated
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Attributed agent background workers<select id="profile-agentBackgroundWorkers"><option value="inherit">Inherit baseline and grant</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspectLine truncated
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.</p></section>
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-vault:prepare-submit" type="checkbox"> Fill and authorize one approved login POST (human-unlocked vault)</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Export inspected foreground page viewport</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.</Line truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><fieldset><legend>Controlled credential submission</legend><label class="checks"><input id="vault-controlled-submit" type="checkbox"> Allow one approved vault login POST</label><p id="vault-controlled-submit-help">Disabled by default. A person must unlock the local vault. Ordinary autofill does not authorize submission. This permits a short-lived, one-use allowance for exact credential fields at an approved same-origin HTTPS POST target; every other field remains subject to DLP. Preparation fills the form but never submits it or exports passwords. This is not proof of website identity or a guarantee against phishing. Agent preparation additionally requires its separate capability. Changes require signed policy publication and independent review when configured.</p><label>Approved login POST rules (metadata only, maximum 32)<textarea id="vault-submit-forms" rows="9" maxlength="150000" spellcheck="false" placeholder='[{"id":"company-login","actionUrl":"https://login.company.example/signin","usernameField":"username","passwordField":"password"}]'></textarea></label><p>Use exact canonical HTTPS action URLs without query strings, fragments or URL credentials. Rule identifiers and target/field signatures must be unique; username and password field names must differ and use printable ASCII (maximum 128 characters). Never enter usernames, passwords, tokens or other credential values here.</p></fieldset><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</lLine truncated
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label class="checks"><input id="agent-background-workers" type="checkbox"> Development: permit attributed agent background workers</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-vault:prepare-submit" type="checkbox"> Fill and authorize one approved login POST (human-unlocked vault)</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-network:background" type="checkbox"> Permit attributed background network (not an RPC action)</label><label><input id="agent-scope-screenshot" type="checkbox"> Export inspected foreground page viewport</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">History read and history navigation are separate capabilities. Line truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><fieldset><legend>Controlled credential submission</legend><label class="checks"><input id="vault-controlled-submit" type="checkbox"> Allow one approved vault login POST</label><p id="vault-controlled-submit-help">Disabled by default. A person must unlock the local vault. Ordinary autofill does not authorize submission. This permits a short-lived, one-use allowance for exact credential fields at an approved same-origin HTTPS POST target; every other field remains subject to DLP. Preparation fills the form but never submits it or exports passwords. This is not proof of website identity or a guarantee against phishing. Agent preparation additionally requires its separate capability. Changes require signed policy publication and independent review when configured.</p><div id="vault-rule-editor"></div><button id="vault-rule-add" type="button" class="secondary">Add approved login target</button><p id="vault-rule-status" role="status" aria-live="polite"></p><details><summary>Advanced login rule JSON</summary><label>Approved login POST rules (metadata only, maximum 32)<textarea id="vault-submit-forms" rows="9" maxlength="150000" spellcheck="false" placeholder='[{"id":"company-login","actionUrl":"https://login.company.example/signin","usernameField":"username","passwordField":"password"}]'></textarea></label><p>Use exact canonical HTTPS action URLs without query strings, fragments or URL credentials. Rule identifiers and target/field signatures must be unique; username and password field names must differ and use printable ASCII (maximum 128 characters). Never enter usernames, passwords, tokens or other credential values here.</p></details></fieldset><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard texLine truncated
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
+1 -1
View File
@@ -1,6 +1,6 @@
/* Profiles add restrictions; reviewed developer grants require central delegation. */
let profileState={version:0,profiles:[],categories:[],delegation:{devTools:false},devices:[]},selectedProfile='';
const profileFeatureNames=['devTools','downloads','ai','agents'];
const profileFeatureNames=['devTools','downloads','ai','agents','agentBackgroundWorkers'];
function profileDraft(){return {id:$('profile-id').value.trim(),name:$('profile-name').value.trim(),subjects:lines('profile-subjects'),deviceIds:groupSelected('profile-devices'),allowedHosts:lines('profile-allowed'),blockedHosts:lines('profile-blocked'),blockedCategoryIds:groupSelected('profile-categories'),features:Object.fromEntries(profileFeatureNames.map(key=>[key,$(key==='devTools'?'profile-devtools':`profile-${key}`).value])),requireDocumentScan:$('profile-scan').checked};}
function editProfile(profile){selectedProfile=profile?.id||'';$('profile-editor-title').textContent=profile?`Edit ${profile.name}`:'Create profile';$('profile-id').value=profile?.id||'';$('profile-id').readOnly=!!profile;$('profile-name').value=profile?.name||'';$('profile-subjects').value=(profile?.subjects||[]).join('\n');$('profile-allowed').value=(profile?.allowedHosts||[]).join('\n');$('profile-blocked').value=(profile?.blockedHosts||[]).join('\n');groupChecks('profile-devices',profileState.devices,profile?.deviceIds||[]);groupChecks('profile-categories',profileState.categories,profile?.blockedCategoryIds||[]);for(const key of profileFeatureNames)$(key==='devTools'?'profile-devtools':`profile-${key}`).value=profile?.features?.[key]||'inherit';$('profile-scan').checked=profile?.requireDocumentScan===true;$('profile-preview-summary').textContent='';$('profile-preview-diff').replaceChildren();applyProfilePermissions(currentPrincipal);}
function applyProfilePermissions(principal){if(!principal)return;const permissions=new Set(principal.permissions||[]),write=permissions.has('policy.write')||(independentApprovalRequired&&permissions.has('policy.prepare'));for(const field of $('user-profiles').querySelectorAll('input,textarea,select,button'))field.disabled=!write;for(const id of ['profiles-refresh','profile-preview-device','profile-preview'])$(id).disabled=!permissions.has('policy.read');for(const button of $('profiles-rows').querySelectorAll('button'))button.disabled=!permissions.has('policy.read');for(const button of $('profile-category-editor').querySelectorAll('button'))if(button.dataset.categoryReferenced==='true')button.disabled=true;$('profile-delete').disabled=!write||!selectedProfile;$('profile-save').textContent=independentApprovalRequired?'Prepare profile for independent approval':'Publish profile';$('profile-config-save').textContent=independentApprovalRequired?'Prepare categories and delegation for approval':'Publish categories and delegation';}