Harden Windows DLP enforcement and Electron runtime packaging
This commit is contained in:
1 parent
e7d778aeac
commit
8a253eda89
120 files changed
+3569
-135
No files matched your search
@@ -65,3 +65,7 @@ EMBEDDING_MODEL_REVISION=
|
||||
# Optional external owner-only ML-DSA-87 PKCS8 PEM audit signing key.
|
||||
# Once enabled, restarting without the same key fails; archive public pin/head externally.
|
||||
AUDIT_SIGNING_KEY_FILE=
|
||||
# Optional upstream proxy credentials require an exact endpoint pin.
|
||||
# These are deployment-only; never publish passwords in policy bundles.
|
||||
SAFETYNET_PROXY_AUTH_HOST=
|
||||
SAFETYNET_PROXY_AUTH_PORT=
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
SafetyNet combines an Electron/Chromium desktop browser, an administrator web console, a local document-inspection service, and a controlled AI gateway. Employees can use approved AI tools while administrators decide which information may leave the organization and where it may go.
|
||||
|
||||
The current milestone is the [Windows DLP MVP](docs/windows-dlp-mvp.md): enforce
|
||||
confidential-content checks before browser clipboard release and outbound text
|
||||
or file transmission, with central policy and local semantic inspection.
|
||||
Native public/blocked transfer and restart fixtures pass. Final acceptance is
|
||||
still open: a reproducible Chromium WebRTC renderer crash in initial blank
|
||||
iframes requires a verified native fix. Other platforms and broader features
|
||||
remain outside this milestone.
|
||||
|
||||
S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged.
|
||||
|
||||
## A look inside
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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,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)})};}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,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)})]});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
@@ -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);}
|
||||
};
|
||||
|
||||
@@ -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();}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');}
|
||||
@@ -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});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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));});
|
||||
}
|
||||
|
||||
@@ -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}};
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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 *.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,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';}
|
||||
|
||||
@@ -6,7 +6,15 @@ Local password vault section. The new policy defaults off and requires explicit
|
||||
approved form rules. Human vault unlock remains required. No agent can unlock the
|
||||
vault or export passwords through this feature.
|
||||
|
||||
An approved rule contains metadata only:
|
||||
Use Add approved login target to edit rule ID, exact action URL and the two field
|
||||
names in typed rows. Removing a row updates only the unpublished draft. Advanced
|
||||
JSON remains available; invalid or unknown metadata is rejected instead of silently
|
||||
discarded. Auditor controls are read-only and normal independent review remains
|
||||
authoritative. An actual Windows/Electron workflow created the rule through
|
||||
these four typed fields, verified independent signed publication/history restore
|
||||
and auditor read-only controls. The screenshot
|
||||
`artifacts/credential-submission-admin-controls.png` shows the synthetic rule;
|
||||
no credential values are entered. An approved rule contains metadata only:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# JavaScript lint and dependency audit
|
||||
|
||||
Audit date: 2026-10-13. `npm run lint` uses pinned ESLint 10.12.0, `@eslint/js` 10.0.1 and `globals` 17.13.0. Flat configuration enables recommended correctness rules, including undefined identifiers, unsafe control flow, duplicate keys and unreachable code. Node and renderer environments are separate. Serialized isolated-world DOM helpers and classic admin scripts have explicit global contracts. Narrow annotated exceptions preserve intentional control-character rejection, fixed sanitized errors, cleanup failure propagation and sensitive transient text clearing. Unused-variable warnings remain enabled, and the lint command fails on any warning. Existing unused imports and pure dead locals were removed; intentional field-stripping destructures have narrow documented annotations.
|
||||
|
||||
Configuration follows the official [flat-config documentation](https://eslint.org/docs/latest/use/configure/configuration-files) and [language-options documentation](https://eslint.org/docs/latest/use/configure/language-options).
|
||||
|
||||
`npm audit --omit=dev --json` reports zero npm advisories in production dependencies. Full `npm audit --json` reports eight moderate affected packages and zero high/critical. These eight entries form one development-tool dependency chain, rather than eight independent vulnerabilities:
|
||||
|
||||
`electron-builder → app-builder-lib → @electron/get → global-agent → roarr → [email protected]`.
|
||||
|
||||
The underlying [GHSA-hp3w-g68c-fv3c / CVE-2026-97058](https://github.com/advisories/GHSA-hp3w-g68c-fv3c) concerns denial of service when attackers control numeric precision in sprintf format strings. The advisory lists versions through 1.1.3 as affected and no patched release. The registry's latest sprintf-js is also 1.1.3. Consequently there is currently no verified patched leaf version to pin. Npm proposes electron-builder 26.5.0 despite the current pinned 26.15.3; no blind downgrade or `audit fix --force` was performed.
|
||||
|
||||
This is a remaining build-toolchain risk. Keep package generation limited to reviewed local source/configuration, avoid attacker-supplied format templates, and recheck the advisory before release. A production npm dependency result does not audit bundled Chromium/Electron, Python/OCR/model runtimes or prove absence of vulnerabilities. Exact Electron remains pinned at 44.7.0. Package fuse tooling is pinned separately as `@electron/[email protected]`; its package fuse enforcement requires a successful package verification result.
|
||||
|
||||
Raw reports remain in ignored local artifacts: `artifacts/npm-audit-production.json`, `artifacts/npm-audit-full.json`, `artifacts/eslint-final.json`. Repeat both audit commands when dependencies change; do not treat this dated snapshot as permanent evidence.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Electron quality and performance review
|
||||
|
||||
The current Windows DLP MVP keeps security enforcement as the priority. This
|
||||
review uses the official Electron documentation as the implementation authority:
|
||||
[security](https://www.electronjs.org/docs/latest/tutorial/security),
|
||||
[performance](https://www.electronjs.org/docs/latest/tutorial/performance),
|
||||
[context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation),
|
||||
[preloads](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload),
|
||||
[IPC](https://www.electronjs.org/docs/latest/tutorial/ipc),
|
||||
[coding style](https://www.electronjs.org/docs/latest/development/coding-style)
|
||||
and [fuses](https://www.electronjs.org/docs/latest/tutorial/fuses).
|
||||
|
||||
## Decisions and implementation
|
||||
|
||||
- Keep sandboxing, context isolation, no page Node integration, constrained
|
||||
preload APIs, sender/frame validation and input validation. These need runtime
|
||||
and negative tests; configuration alone does not prove a secure browser.
|
||||
- Add pinned ESLint tooling and an explicit Node/browser environment contract.
|
||||
Do not hide findings through blanket rule disabling. Dependency advisory
|
||||
results and lint completion are tracked separately from syntax checks.
|
||||
- Profile before optimizing. `scripts/profile-browser-modules.mjs` works with
|
||||
Node CPU/heap profiling flags; output belongs in ignored `artifacts`.
|
||||
- Defer OpenTelemetry SDK/exporter loading until diagnostics are authorized
|
||||
and enabled. Keep the lightweight diagnostics API available immediately.
|
||||
This shifts initialization cost to first enablement; it does not remove that
|
||||
cost or claim a measured improvement to full browser startup. Seventeen focused
|
||||
diagnostics regression tests pass after this change.
|
||||
- Load text-normalization code on its first inspection rather than immediately
|
||||
upon requiring its wrapper; subsequent calls reuse the module.
|
||||
- Keep parsing, OCR and model inference in the isolated worker. Do not move
|
||||
expensive inspection into the renderer or postpone mandatory enforcement.
|
||||
- Review main-process synchronous filesystem/cryptographic operations using
|
||||
traces before changing behavior. Build CLI `spawnSync` is separate from the
|
||||
interactive browser event loop.
|
||||
|
||||
## Bundling and architecture
|
||||
|
||||
[Electron Forge](https://www.electronforge.io/) and the
|
||||
[official bundling recommendation](https://www.electronjs.org/docs/latest/tutorial/performance)
|
||||
are useful options. The existing electron-builder pipeline already verifies
|
||||
Windows runtime contents. Migrating scaffolding is not itself a DLP improvement.
|
||||
Any bundling/code-splitting change must retain sandbox-compatible preloads,
|
||||
dynamic imports, packaged resources, CSP and exact package verification, and
|
||||
must show measured startup or resource improvements before adoption.
|
||||
|
||||
The [Palette performance article](https://palette.dev/blog/improving-performance-of-electron-apps)
|
||||
was inspected for additional profiling ideas. Type-safe IPC examples are useful
|
||||
for maintainability; TypeScript types cannot replace runtime validation of
|
||||
untrusted messages. No new IPC framework is needed merely to implement that rule.
|
||||
|
||||
## Source freshness
|
||||
|
||||
The dependency audit reports zero production advisories and eight moderate
|
||||
development/build-toolchain entries, with no high or critical entries at this
|
||||
review point. The underlying `sprintf-js` precision-format denial-of-service
|
||||
advisory has no patched release reported; suggested electron-builder downgrade
|
||||
is not accepted as a safe remediation. These findings remain explicit tooling
|
||||
risk, not evidence that the deployed browser is vulnerability-free.
|
||||
|
||||
The packaged executable's existing fuse wire was inspected separately from
|
||||
JavaScript startup checks. Run-as-Node, Node environment/CLI inspection options
|
||||
and ASAR enforcement need package-level hardening and rebuilt-artifact proof.
|
||||
All six hardened fuse values and the matching embedded ASAR header hash were
|
||||
verified in a rebuilt executable. Actual RUN_AS_NODE and NODE_OPTIONS negative
|
||||
launches passed. The CLI-inspector fixture found a startup error dialog instead
|
||||
of process termination; the source now exits with a sanitized rejection, but
|
||||
the corrected rebuilt package subsequently passed actual negative launches,
|
||||
including eleven certificate/feature override cases and copied-ASAR tampering.
|
||||
The manipulated archive was rejected before application entry; original archive
|
||||
and frozen packaged sources remained unchanged. Evidence:
|
||||
`artifacts/package-fuses-proof.json`. Actual signing status remains `NotSigned`.
|
||||
|
||||
The supplied Reasonably Secure Electron URL contained a typographic dash.
|
||||
Its correct repository is
|
||||
[moloch--/reasonably-secure-electron](https://github.com/moloch--/reasonably-secure-electron),
|
||||
archived in June 2024; treat it as historical architecture context rather than
|
||||
current Electron defaults. The supplied dev.to performance page could not be
|
||||
retrieved and was not used as implementation evidence. Other supplied articles
|
||||
were inspected, but version-specific changes must be checked against official
|
||||
documentation and the actual installed runtime.
|
||||
|
||||
This review is in progress. Green syntax/tests and one profiling run do not prove
|
||||
that the whole application is clean, fully optimized or independently audited.
|
||||
|
||||
## Latest reproducible checks
|
||||
|
||||
- Full source suite: 749 tests passed, no failures or skips; see the ignored
|
||||
`artifacts/mvp-final-startup-suite.log`. `npm run lint` passed again with
|
||||
zero warnings on 2026-10-13.
|
||||
- A fresh `npm audit --omit=dev --json` reported zero advisories. Development
|
||||
toolchain findings remain tracked separately in `dependency-audit.md`.
|
||||
- In the paired single module-load profiles, diagnostics require time changed
|
||||
from 260.119 ms to 67.001 ms after deferred SDK loading. Recorded heap deltas
|
||||
changed from 6,674,088 to 2,460,832 bytes. Both used CPU/heap profiling; these
|
||||
measurements do not establish full application startup or percentile gains.
|
||||
- Native Windows DLP fixtures delivered public data and blocked confidential
|
||||
literal and semantic payloads. Restart reused the protected device profile.
|
||||
These are separate from final packaged-artifact acceptance.
|
||||
- The protected local OpenTelemetry receiver reported zero rejected writes.
|
||||
Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request,
|
||||
browser.inspect.local, inspector.inspect, semantic load/inference and durable
|
||||
audit stages. The public-file request took 8966 ms in this synthetic run;
|
||||
a contained local text scan took 1611 ms and model load 662 ms. This identifies
|
||||
repeated model initialization as a measurement target, not permission to
|
||||
weaken process isolation or mandatory scanning.
|
||||
- A native WebRTC initial-about:blank renderer crash remains reproducible under
|
||||
Chromium's connection allowlist. The upstream no-op binder fix is recorded
|
||||
in [the upstream regression tests](https://github.com/web-platform-tests/wpt/pull/63420).
|
||||
The separately tested Electron nightly also lacks it. The Windows MVP is not
|
||||
declared stable until an actual fixed runtime passes this negative fixture.
|
||||
- The full enrolled browser WebTransport receiver fixture now passes through
|
||||
reviewed DIRECT and fetched HTTPS PAC policies: zero managed packets/bytes,
|
||||
paired against 132 baseline bytes, with ordinary HTTPS submissions working.
|
||||
It records source hashes and genuine operational traces. There is still no
|
||||
per-attempt WebTransport security event because this path produces no
|
||||
Electron webRequest callback; transport denial and audit coverage are separate.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Electron security review — 2026-10-13
|
||||
|
||||
This is a read-only review of the current source and unpacked Windows executable. It is not a penetration test or a claim of complete Chromium DLP coverage.
|
||||
|
||||
## Official references
|
||||
|
||||
Electron recommends isolated, sandboxed remote renderers, constrained IPC and navigation, current dependencies, and consideration of custom protocols and packaging fuses. [Security](https://www.electronjs.org/docs/latest/tutorial/security).
|
||||
|
||||
A bridge should expose specific actions, rather than raw IPC dispatchers. [Context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation), [Preload scripts](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload), [IPC](https://www.electronjs.org/docs/latest/tutorial/ipc).
|
||||
|
||||
The subframe option is documented as experimental; sandboxing disables the renderer Node engine. [WebPreferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences).
|
||||
|
||||
Fuses can disable alternate startup behavior and enforce ASAR loading/integrity; cookie encryption is another available control. [Fuses](https://www.electronjs.org/docs/latest/tutorial/fuses).
|
||||
|
||||
## Implemented boundaries inspected
|
||||
|
||||
| Boundary | Current evidence | Assessment |
|
||||
| --- | --- | --- |
|
||||
| Remote renderer | main.cjs creates WebContentsView with nodeIntegration:false, contextIsolation:true, sandbox:true, webSecurity:true, allowRunningInsecureContent:false; app.enableSandbox() runs before renderer creation | Correct explicit baseline |
|
||||
| Website preload | site-preload.cjs asserts sandboxed/contextIsolated, exports no bridge; isolated fixed guards only | No raw IPC exposed to page |
|
||||
| Trusted UI bridge | preload.cjs exposes fixed channel methods; onState strips event argument and supplies unsubscribe | Matches constrained bridge pattern; trusted chrome still has powerful actions |
|
||||
| Privileged IPC sender | main.cjs ipc helper requires exact window WebContents, main frame and packaged UI URL | Remote and child frames excluded from trusted UI operations |
|
||||
| Website reporting IPC | visual-check and page-content bind managed tab and main frame; visual reports validate bounded token/revision/URL | Separate limited channels, not generic dispatch |
|
||||
| Permissions and certificates | browserSession request/check handlers deny; certificate-error callback denies | Default deny |
|
||||
| Navigation/windows | trusted chrome cannot navigate/open children; managed sites checked by destination policy and agent authority, popups routed to guarded native tabs | Policy-aware handling |
|
||||
| UI CSP | index.html self scripts/styles, connect-src none, object/base/form restrictions | Restrictive packaged UI CSP |
|
||||
| Startup | startup-security.cjs rejects sandbox/security/isolation bypass and external debugger switches; tests/startup-security.test.mjs | Useful after-entry guard, cannot replace binary fuses |
|
||||
|
||||
## Experimental subframe setting
|
||||
|
||||
nodeIntegrationInSubFrames:true currently ensures the security preload reaches iframes. It is not evidence that page Node access is enabled: scripts/electron-smoke.cjs explicitly checks srcdoc child require/process/safetynet are undefined and sandbox remains true. scripts/native-tabs-smoke.cjs checks require and bridge absence for active, inactive and replacement tabs. site-preload.cjs additionally fails closed if isolation or sandbox changes.
|
||||
|
||||
Do not remove this setting solely because its name contains Node. A replacement must retain srcdoc, initial about:blank, cross-origin/OOPIF clipboard protection and the real native negative tests. The setting remains experimental and increases maintenance risk; test it on every Electron upgrade. Current assertions are not a proof against a framework sandbox vulnerability.
|
||||
|
||||
## Historical baseline findings
|
||||
|
||||
The following findings describe the initial artifact. Later package/startup
|
||||
follow-ups below supersede the fuse and launch findings; the custom UI scheme,
|
||||
native RTC stability and production distribution gates remain open.
|
||||
|
||||
1. **Historical binary fuse gap, superseded by rebuilt readback below.** An earlier direct read of dist/win-unpacked/SafetyNet.exe found V1 fuse wire 101100011. That older executable enabled RunAsNode, NODE_OPTIONS and CLI inspector and disabled cookie encryption, ASAR integrity and ASAR-only loading. This is historical evidence, not the current fuse status. Final runtime negative probes remain necessary.
|
||||
2. **Trusted UI uses file://.** main.cjs builds uiUrl with pathToFileURL and calls loadFile. A dedicated standard secure custom scheme with a strict packaged asset allowlist would reduce file-origin privilege. Migration must update exact trusted-sender URL checks and every native smoke harness; do not enable generic file-system routing or bypassCSP.
|
||||
3. **Subframe test breadth.** The inspected native assertion explicitly proves a same-origin srcdoc frame. Extend capability assertions to real cross-origin/OOPIF, initial blank and nested frames alongside existing clipboard bypass tests. No broad assertion about all frame configurations is justified by one fixture.
|
||||
4. **Production distribution.** Source pinning and verification do not provide code signing, protected install ACLs or signed updates. External production trust requirements remain open.
|
||||
|
||||
## Proposed packaging patch and compatibility
|
||||
|
||||
After electron-builder finishes creating win-unpacked, use an explicitly pinned @electron/fuses dependency to flip RunAsNode=false, EnableNodeOptionsEnvironmentVariable=false, EnableNodeCliInspectArguments=false, EnableCookieEncryption=true, OnlyLoadAppFromAsar=true and EnableEmbeddedAsarIntegrityValidation=true on the produced executable. Confirm builder embedded the matching ASAR integrity metadata before enabling integrity; otherwise packaging must fail rather than weaken the setting. Record/read back all fuse values in verify-package.mjs.
|
||||
|
||||
Current verify-package.mjs launches SafetyNet.exe with --agent-action and --agent-connection through normal main.cjs and app.whenReady(), not ELECTRON_RUN_AS_NODE. Inspector/inference helpers are external Python/native binaries. No child_process.fork dependency was found in inspected browser/package paths. Thus disabling Node-mode and inspector startup is compatible with the documented current architecture, but only a rebuilt artifact and real packaged verification can prove it.
|
||||
|
||||
Add finite negative launch checks for RUN_AS_NODE, NODE_OPTIONS preload injection and inspector flags, plus an isolated copied-ASAR tamper test. A JS source hash comparison alone does not verify runtime archive integrity enforcement. Cookie encryption changes require a migration/reopen fixture for persistent managed sessions.
|
||||
|
||||
## Implementation follow-up (verification pending)
|
||||
|
||||
scripts/package.mjs now supplies all six hardened fuse settings through electron-builder's supported electronFuses configuration. This runs against the copied executable before signing. scripts/package-fuses.mjs validates the resulting fuse wire and parses the Windows PE INTEGRITY/ELECTRONASAR resource against the exact archive header hash. The installed Electron distribution is not modified.
|
||||
|
||||
Two focused unit checks passed. scripts/verify-package-fuses.mjs supplies finite actual-process negative checks and a copied archive tamper fixture; it must pass on the rebuilt artifact before this gap is considered closed. The previously measured executable remains unhardened until packaging runs. The custom-scheme gap remains open.
|
||||
|
||||
## Rebuilt artifact verification
|
||||
|
||||
The coordinated Windows build completed with all six hardened fuses and a matching embedded ASAR header hash. The standard packaged scanner/runtime/agent verifier passed. Actual negative launches prove RUN_AS_NODE cannot change the normal application entry point and a NODE_OPTIONS require injection does not execute.
|
||||
|
||||
The earlier CLI-inspector negative launch exposed an operational defect: Electron's default uncaught-exception handler opened an error box without terminating. The verifier correctly failed on this timeout. Current main.cjs catches startup rejection, emits a fixed message and calls process.exit(1). Final rebuilt negative launch and copied-ASAR tamper probes remain running/pending; absence of an inspector listener alone does not prove terminal behavior.
|
||||
|
||||
## Independent final source review
|
||||
|
||||
A fresh `npm run lint` passed with zero warnings allowed. `artifacts/mvp-latest-integrated-suite.log` records 748 passed tests and zero failures. These are bounded scenario evidence, not proof of every Chromium transport path. This review changed documentation only.
|
||||
|
||||
Current source proves explicit global sandboxing, context isolation and disabled Node integration for remote views; fixed UI bridge methods without raw IPC/event exposure; exact trusted WebContents/main-frame/URL sender checks; denied remote permissions; constrained navigation and popup handling. These match the primary [Security](https://www.electronjs.org/docs/latest/tutorial/security), [Context Isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) and [IPC](https://www.electronjs.org/docs/latest/tutorial/ipc) guidance. The experimental subframe option and file-origin UI retain the limitations stated above.
|
||||
|
||||
Current rebuilt fuse readback was independently verified with `verifyPackagedFuses`: RunAsNode=false, CookieEncryption=true, NodeOptions=false, NodeCliInspect=false, EmbeddedAsarIntegrity=true and OnlyLoadAppFromAsar=true. The Windows embedded archive-header hash matched. This supersedes the historical fuse wire; final negative launch/tamper probes on the final source-matched package remain pending.
|
||||
|
||||
`artifacts/webtransport-native-proof.json` establishes actual page and dedicated-worker delivery to a reachable pinned local HTTP/3 server in the baseline: 132 bytes, including 66 worker bytes, with zero webRequest callbacks. Neither tested disable flag stopped transmission. The authenticated production relay module blocked all receiver packets/bytes while ordinary HTTP remained usable. Main now installs the non-DIRECT relay before tab creation and restores it on policy application. This proves the standalone Session/module boundary; a full enrolled main.cjs page-and-worker receiver test across policy application remains necessary. Chromium's [dedicated WebTransport client](https://raw.githubusercontent.com/chromium/chromium/main/net/quic/dedicated_web_transport_http3_client.cc) rejects a resolved non-DIRECT proxy before socket creation. Revalidate after every Electron upgrade; no claim is made about future HTTP/2 WebTransport implementations.
|
||||
|
||||
**Critical unresolved stability gate:** the initial-about:blank native RTC fixture records exit code 3. It must not be presented as completed or stable protection. Source guards and passing unit tests do not close this crash or prove every nested/cross-origin context.
|
||||
|
||||
**Startup trust follow-up:** the reviewed denylist accepted certificate-ignore switches and user feature overrides. Root now rejects certificate-ignore/SPKI-ignore, insecure localhost/origin and reduced-security switches. Original application argv rejects user enable/disable-feature and experimental Blink overrides; Electron's internal default disable-features list is preserved. Seven focused startup tests and the updated 749-test suite passed. Actual packaged negative launches for these additional switches are pending a fresh build. The native network gate is `ConnectionAllowlists` (plural), as shown in Chromium's [navigation throttle](https://raw.githubusercontent.com/chromium/chromium/main/content/browser/renderer_host/network_restrictions_navigation_throttle.cc); a singular guessed feature name is not enforcement evidence.
|
||||
|
||||
Inspection/inference runs outside the main process, but synchronous audit encryption, writes/fsync and some startup reads remain on it. Traces provide timings for exercised scenarios; no universal responsiveness claim is proven. See Electron's [Performance guidance](https://www.electronjs.org/docs/latest/tutorial/performance).
|
||||
|
||||
### Final package follow-up
|
||||
|
||||
The new frozen-source Windows build and both package verifiers passed after
|
||||
the startup correction. Actual baseline, RUN_AS_NODE, NODE_OPTIONS, CLI
|
||||
inspector, eleven certificate/feature override launches and copied-ASAR tampering
|
||||
were exercised. All unsafe startup cases terminated with the fixed rejection;
|
||||
tampering failed before application entry. The original archive remained
|
||||
unchanged. `artifacts/package-fuses-proof.json` records
|
||||
`unsafeStartupOverridesRejected=true`. This supersedes the pending package
|
||||
statements above. Actual executable status remains `NotSigned`.
|
||||
|
||||
### Minimal remaining enrolled WebTransport harness
|
||||
|
||||
Implementation follow-up: the enrolled main.cjs harness now passed, recorded in
|
||||
`artifacts/fullmain-webtransport-native-proof.json`. The unguarded Window/Worker
|
||||
baseline delivered 132 bytes. Actual enrollment, reviewed DIRECT and fetched
|
||||
HTTPS PAC→DIRECT policies each yielded zero managed receiver packets and bytes,
|
||||
while three ordinary HTTPS POSTs succeeded. Actual correlated operational
|
||||
traces exported with zero current/lifetime failures. Exact source hashes are
|
||||
included; the later change only normalizes Windows startup arguments. No
|
||||
webRequest callback or individual WebTransport audit event was invented. This
|
||||
supersedes the proposed-only status below; per-attempt security auditing remains
|
||||
open.
|
||||
|
||||
Extend the Windows DLP smoke parent to start the existing pinned local HTTP/3 receiver and pass its ephemeral URL and certificate hash to the native fixture. First prove baseline marker delivery. After actual S Browser enrollment, attempt a datagram and stream from the managed page and a real dedicated Worker. Assert receiver connection/byte deltas, then repeat after signed policy application, including enterprise DIRECT/PAC configurations. Require ordinary HTTP/HTTPS and benign prompt delivery to keep working. Record source hashes and fixed facts without credentials or prompt contents. This extension is proposed; it is not implemented or passed here.
|
||||
|
||||
Upstream handler evidence: [Electron browser initialization](https://github.com/electron/electron/blob/main/lib/browser/init.ts). The verifier now records a fixed stage and bounded diagnostic output before reporting failure, and waits for child termination before removing its own validated temporary directory.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Windows browser transport containment
|
||||
|
||||
Electron's HTTP request hooks do not intercept WebTransport payloads. A native
|
||||
receiver test with Electron 44.7.0 demonstrated data from both a page and a
|
||||
dedicated worker bypassing those hooks, including with the tested QUIC/Blink
|
||||
disable flags. The implementation therefore forces every managed browser URL
|
||||
through a private non-DIRECT Chromium proxy, with implicit loopback bypass removed.
|
||||
The tested Chromium HTTP/3 WebTransport client rejects that proxy route before
|
||||
opening its UDP connection. Ordinary HTTP and HTTPS remain available.
|
||||
|
||||
`local-dlp-relay.cjs` binds numeric loopback only and authenticates Chromium with
|
||||
an ephemeral capability. It forwards approved HTTP or CONNECT traffic; it is not
|
||||
a replacement for the native content-DLP gates. Native hooks still inspect text,
|
||||
files and headers before release. Policy replacement, quarantine and shutdown
|
||||
invalidate relay authority and terminate pending/live sockets. Resolution uses
|
||||
the approved Chromium resolver and numeric connection addresses.
|
||||
|
||||
The management session retains the centrally configured system/fixed/PAC proxy.
|
||||
The relay resolves upstream routes through that session. DIRECT, HTTP PROXY and
|
||||
HTTPS PROXY routes are supported; an unsupported or failed route never silently
|
||||
falls back to DIRECT. SOCKS and integrated upstream NTLM/Kerberos authentication
|
||||
currently fail closed and require an additional integration. Optional Basic
|
||||
credentials require `SAFETYNET_PROXY_AUTH_HOST` and `SAFETYNET_PROXY_AUTH_PORT`
|
||||
to match the selected upstream endpoint, in addition to protected deployment
|
||||
username/password variables. They are not distributed in policy bundles.
|
||||
|
||||
Native evidence: `artifacts/webtransport-native-proof.json`. The receiver got
|
||||
zero packets/bytes with the private relay while ordinary HTTP loaded. The
|
||||
Windows core DLP and real restart fixtures also passed with relay integration.
|
||||
Chromium upgrades must repeat this receiver proof; it is version-specific.
|
||||
WebTransport-specific denial audit events are not yet observed because the
|
||||
native rejection occurs before existing HTTP/relay callbacks. Do not claim
|
||||
complete transport-attempt SIEM coverage from the ordinary DLP event pipeline.
|
||||
|
||||
Inspected WebSocket/WebRTC support remains the intended compatibility goal.
|
||||
Uninspected transports remain restricted until their content gates have their
|
||||
own actual receiver and bypass tests.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Chromium 152 WebRTC binder backport
|
||||
|
||||
Status: source patch prepared and checked; **not compiled or runtime verified**.
|
||||
Do not distribute this patch as a proven security fix until the acceptance gates
|
||||
below pass against the resulting binary.
|
||||
|
||||
## Exact basis and provenance
|
||||
|
||||
The adjacent patch targets Chromium `152.0.7977.130`, used by Electron `44.7.0`.
|
||||
It backports Chromium commit
|
||||
[`46fcf3faed9db394de7046a100301f64c2842f0e`](https://chromium.googlesource.com/chromium/src/+/46fcf3faed9db394de7046a100301f64c2842f0e)
|
||||
(CL8485457, bug565837691). Upstream files retain their Chromium copyright notices.
|
||||
|
||||
The upstream change fixes a native renderer termination when nested local-scheme
|
||||
frames request a missing WebRTC binder under `Connection-Allowlist`. It registers
|
||||
the binders consistently, checks the current document policy when binding, and
|
||||
uses no-op mDNS/socket implementations to fail without creating network sockets.
|
||||
It is a temporary transport denial, not WebRTC payload inspection.
|
||||
|
||||
Only two context adaptations were needed: insert the policy helper before the
|
||||
older utilities file's namespace close, and place the conditional mDNS include
|
||||
beside the existing connection-policy include. The original implementation,
|
||||
unit tests, WPT tests and GN entries are preserved. The older public
|
||||
`RenderFrameHost` header exposes both APIs used by the helper:
|
||||
`HasPolicyContainerHost()` and `GetConnectionAllowlists()`.
|
||||
|
||||
On 2026-10-13, the 16 exact touched paths were retrieved individually from the
|
||||
official Chromium tag into an ignored scratch repository. Unmodified tag files
|
||||
were restored, with LF line endings, and this command completed with exit0:
|
||||
|
||||
```powershell
|
||||
git -C artifacts/rtc-patch-check apply --check C:/Users/karl/Safetynet/docs/native-runtime/chromium-152-webrtc-binder-backport.patch
|
||||
```
|
||||
|
||||
This proves textual applicability to the tagged files. It does not establish
|
||||
compilation, compatibility with Electron's existing patches, or runtime safety.
|
||||
Repeat the check against the complete Electron-patched source checkout.
|
||||
|
||||
## Host readiness
|
||||
|
||||
Measured host: Ryzen7800X3D, eight exposed logical processors, 16GB RAM (about
|
||||
4.5GB free during inspection), and 266.6GiB free on NTFS C:. Installed VS2022
|
||||
Build Tools17.14 includes x64 C++ tools but lacks the ATL/MFC component. SDK
|
||||
`rc.exe` is26100.7705; `symstore.exe` is28000.2957. No native checkout or
|
||||
depot_tools/gclient/GN/Ninja installation was found on PATH.
|
||||
|
||||
The [version-specific Chromium requirements](https://chromium.googlesource.com/chromium/src/+/152.0.7977.130/docs/windows_build_instructions.md)
|
||||
specify at least8GB RAM and100GB disk, recommend more than16GB RAM, and require
|
||||
C++ plus ATL/MFC and SDK26100.7705. This host meets the minimum resources but
|
||||
requires toolchain completion. Plan substantially more memory and disk headroom
|
||||
for comfortable builds; actual full-checkout and output size remains unmeasured.
|
||||
Do not automatically disable endpoint antivirus or change global Git settings.
|
||||
|
||||
## Build procedure
|
||||
|
||||
Follow [Electron Windows prerequisites](https://www.electronjs.org/docs/latest/development/build-instructions-windows)
|
||||
and [Electron GN/patch instructions](https://www.electronjs.org/docs/latest/development/build-instructions-gn).
|
||||
Use a separate short path such as `C:/src/electron44`, outside this application.
|
||||
Install missing ATL/MFC and depot_tools first. No large downloads or dependency
|
||||
changes were performed while preparing this review artifact.
|
||||
|
||||
1. Set `DEPOT_TOOLS_WIN_TOOLCHAIN=0` in the build process and prepend depot_tools
|
||||
to that process's PATH. Keep the application workspace unchanged.
|
||||
2. Configure gclient with unmanaged `src/electron` pointing at the official
|
||||
Electron repository. Bootstrap its source, check out exact `v44.7.0` in
|
||||
`src/electron`, then sync with branch heads and tags. Confirm its DEPS pins
|
||||
Chromium152.0.7977.130 before applying anything.
|
||||
3. From `src`, run `git apply --check <absolute-patch-path>` against the already
|
||||
Electron-patched checkout, then apply. Any failure requires review; do not
|
||||
silently drop hunks. Export the change through Electron's patch system
|
||||
(`e patches`) so future synchronizations reproduce it.
|
||||
4. Generate a release configuration using
|
||||
`import("//electron/build/args/release.gn") target_cpu="x64"`.
|
||||
For this constrained host, consider `symbol_level=0`,
|
||||
`blink_symbol_level=0`, `v8_symbol_level=0`, and local execution. These reduce
|
||||
debugging detail; keep a separately instrumented build if native investigation
|
||||
requires symbols.
|
||||
5. Build `ninja -C out/Release -j2 electron`. Build the relevant Chromium native
|
||||
test target as well; low parallelism is an initial resource choice, not a
|
||||
measured guarantee against memory exhaustion.
|
||||
6. Only after verification, package with
|
||||
`ninja -C out/Release electron:electron_dist_zip` and pin artifact hash,
|
||||
Electron/Chromium versions and backport revision in release provenance.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
- Compile and run `NoOpMdnsResponderTest.*` and `NoOpP2PSocketManagerTest.*`.
|
||||
- Execute the upstream local-scheme allow/block WPT cases.
|
||||
- Run real paired WebRTC data-channel positive controls in initial about:blank,
|
||||
srcdoc, blob and cross-origin frames; prove exact synthetic bytes arrive.
|
||||
- Under the actual managed response-header function, repeat the same attempts:
|
||||
no data delivery, no renderer termination, no hanging harness, and successful
|
||||
benign HTTP POSTs. Exercise the initial synchronous iframe window before any
|
||||
navigation or preload; constructor availability alone is not a pass.
|
||||
- Verify native socket denial without relying on the JavaScript preload. Check
|
||||
redirects and a site-supplied `webrtc=allow` header cannot relax managed policy.
|
||||
- Run the complete Windows DLP MVP native acceptance suite and inspect actual
|
||||
exported OpenTelemetry traces, durable deny events and exporter failures.
|
||||
|
||||
## Alternative runtime status
|
||||
|
||||
No tested prebuilt alternative is verified. Electron46 nightly20261013 with
|
||||
Chromium157.0.8091.0 also crashes under the same blocked about:blank/srcdoc
|
||||
attempts; its tagged binder source lacks the no-op implementation. A later
|
||||
binary is acceptable only after source inclusion checks and all native gates
|
||||
above. Current ignored artifacts retain the original crashes and positive
|
||||
controls; an upgrade must not overwrite that evidence.
|
||||
@@ -0,0 +1,903 @@
|
||||
diff --git a/content/browser/BUILD.gn b/content/browser/BUILD.gn
|
||||
index 7b194ea..f73edef 100644
|
||||
--- a/content/browser/BUILD.gn
|
||||
+++ b/content/browser/BUILD.gn
|
||||
@@ -2521,8 +2521,17 @@ source_set("browser") {
|
||||
deps += [ "//media/mojo/mojom" ]
|
||||
}
|
||||
|
||||
+ if (enable_mdns) {
|
||||
+ sources += [
|
||||
+ "renderer_host/no_op_mdns_responder.cc",
|
||||
+ "renderer_host/no_op_mdns_responder.h",
|
||||
+ ]
|
||||
+ }
|
||||
+
|
||||
if (is_p2p_enabled) {
|
||||
sources += [
|
||||
+ "renderer_host/p2p/no_op_p2p_socket_manager.cc",
|
||||
+ "renderer_host/p2p/no_op_p2p_socket_manager.h",
|
||||
"renderer_host/p2p/socket_dispatcher_host.cc",
|
||||
"renderer_host/p2p/socket_dispatcher_host.h",
|
||||
]
|
||||
diff --git a/content/browser/browser_interface_binders.cc b/content/browser/browser_interface_binders.cc
|
||||
index 94c73d9..26ca7ce 100644
|
||||
--- a/content/browser/browser_interface_binders.cc
|
||||
+++ b/content/browser/browser_interface_binders.cc
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "content/browser/bluetooth/web_bluetooth_service_impl.h"
|
||||
#include "content/browser/browser_context_impl.h"
|
||||
#include "content/browser/browser_main_loop.h"
|
||||
+#include "content/browser/connection_allowlist_utils.h"
|
||||
#include "content/browser/contacts/contacts_manager_impl.h"
|
||||
#include "content/browser/content_index/content_index_service_impl.h"
|
||||
#include "content/browser/cookie_store/cookie_store_manager.h"
|
||||
@@ -119,6 +120,7 @@
|
||||
#include "services/device/public/mojom/vibration_manager.mojom.h"
|
||||
#include "services/metrics/public/mojom/ukm_interface.mojom.h"
|
||||
#include "services/metrics/ukm_recorder_factory_impl.h"
|
||||
+#include "services/network/public/cpp/connection_allowlist.h"
|
||||
#include "services/network/public/cpp/features.h"
|
||||
#include "services/network/public/cpp/is_potentially_trustworthy.h"
|
||||
#include "services/network/public/cpp/network_service_buildflags.h"
|
||||
@@ -225,6 +227,7 @@
|
||||
#endif // BUILDFLAG(IS_ANDROID)
|
||||
|
||||
#if BUILDFLAG(IS_P2P_ENABLED)
|
||||
+#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h"
|
||||
#include "services/network/public/mojom/p2p.mojom.h"
|
||||
#endif // BUILDFLAG(IS_P2P_ENABLED)
|
||||
|
||||
@@ -790,6 +793,17 @@ void BindMediaPlayerObserverClientHandler(
|
||||
void BindSocketManager(
|
||||
RenderFrameHost* frame,
|
||||
mojo::PendingReceiver<network::mojom::P2PSocketManager> receiver) {
|
||||
+ // If WebRTC is blocked by the document's Connection Allowlist, bind a no-op
|
||||
+ // P2PSocketManager so that no sockets are ever created, while still letting
|
||||
+ // the renderer fail gracefully (see NoOpP2PSocketManager). It is possible
|
||||
+ // that a renderer which does not allow WebRTC could still reach this
|
||||
+ // interface. TODO(crbug.com/571239543): Disable all WebRTC connections on the
|
||||
+ // renderer side.
|
||||
+ if (!IsWebRTCAllowedByConnectionAllowlist(frame)) {
|
||||
+ NoOpP2PSocketManager::Create(std::move(receiver));
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
static_cast<RenderProcessHostImpl*>(frame->GetProcess())
|
||||
->BindP2PSocketManager(
|
||||
frame->GetIsolationInfoForSubresources().network_anonymization_key(),
|
||||
@@ -991,26 +1005,9 @@ void PopulateBinderMapWithContext(
|
||||
map->Add<blink::mojom::IdleManager>(
|
||||
&BindRenderFrameHostImpl<&RenderFrameHostImpl::BindIdleManager>);
|
||||
|
||||
-#if BUILDFLAG(IS_P2P_ENABLED) || BUILDFLAG(ENABLE_MDNS)
|
||||
- // TODO(447954811): Remove the fenced frames check if we end up supporting
|
||||
- // Connection Allowlist for fenced frames.
|
||||
- bool should_ban_p2p_for_connection_allowlist =
|
||||
- host->HasPolicyContainerHost() &&
|
||||
- host->policy_container_host()
|
||||
- ->connection_allowlists()
|
||||
- .enforced.has_value() &&
|
||||
- host->policy_container_host()
|
||||
- ->connection_allowlists()
|
||||
- .enforced->webrtc_behavior ==
|
||||
- network::ConnectionAllowlist::WebRtcBehavior::kBlock &&
|
||||
- !host->IsNestedWithinFencedFrame();
|
||||
-# endif // BUILDFLAG(IS_P2P_ENABLED) || BUILDFLAG(ENABLE_MDNS)
|
||||
-
|
||||
#if BUILDFLAG(ENABLE_MDNS)
|
||||
- if (!should_ban_p2p_for_connection_allowlist) {
|
||||
- map->Add<network::mojom::MdnsResponder>(
|
||||
+ map->Add<network::mojom::MdnsResponder>(
|
||||
&BindRenderFrameHostImpl<&RenderFrameHostImpl::CreateMdnsResponder>);
|
||||
- }
|
||||
#endif // BUILDFLAG(ENABLE_MDNS)
|
||||
|
||||
// BrowserMainLoop::GetInstance() may be null on unit tests.
|
||||
@@ -1027,18 +1024,7 @@ void PopulateBinderMapWithContext(
|
||||
&RenderFrameHostImpl::CreateNotificationService>);
|
||||
|
||||
#if BUILDFLAG(IS_P2P_ENABLED)
|
||||
- // WebRTC p2p connections are disallowed in fenced frames. Creation of
|
||||
- // RTCPeerConnection is already disabled in the renderer, so in theory this
|
||||
- // unbound interface should never present an issue.
|
||||
- bool should_ban_p2p_for_fenced_frames =
|
||||
- base::FeatureList::IsEnabled(
|
||||
- blink::features::kFencedFramesLocalUnpartitionedDataAccess) &&
|
||||
- host->IsNestedWithinFencedFrame();
|
||||
-
|
||||
- if (!should_ban_p2p_for_fenced_frames &&
|
||||
- !should_ban_p2p_for_connection_allowlist) {
|
||||
- map->Add<network::mojom::P2PSocketManager>(&BindSocketManager);
|
||||
- }
|
||||
+ map->Add<network::mojom::P2PSocketManager>(&BindSocketManager);
|
||||
#endif // BUILDFLAG(IS_P2P_ENABLED)
|
||||
|
||||
map->Add<blink::mojom::PeerConnectionTrackerHost>(
|
||||
diff --git a/content/browser/connection_allowlist_utils.cc b/content/browser/connection_allowlist_utils.cc
|
||||
index 379f029..b366763 100644
|
||||
--- a/content/browser/connection_allowlist_utils.cc
|
||||
+++ b/content/browser/connection_allowlist_utils.cc
|
||||
@@ -141,4 +141,22 @@ bool FrameConnectionAllowlistAllowsRequestAndReportIfNeeded(
|
||||
return ConnectionAllowlistAllowsUrlAndReportIfNeeded(policies, url);
|
||||
}
|
||||
|
||||
+bool IsWebRTCAllowedByConnectionAllowlist(RenderFrameHost* frame) {
|
||||
+ // A speculative RFH doesn't have a PolicyContainerHost, and in theory it
|
||||
+ // should not be binding WebRTC interfaces while speculative. So fail closed.
|
||||
+ if (!frame->HasPolicyContainerHost()) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ const std::optional<network::ConnectionAllowlist>& enforced_ca =
|
||||
+ frame->GetConnectionAllowlists().enforced;
|
||||
+ if (enforced_ca.has_value() &&
|
||||
+ enforced_ca->webrtc_behavior ==
|
||||
+ network::ConnectionAllowlist::WebRtcBehavior::kBlock) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
} // namespace content
|
||||
diff --git a/content/browser/connection_allowlist_utils.h b/content/browser/connection_allowlist_utils.h
|
||||
index ee65754..ab5ecae 100644
|
||||
--- a/content/browser/connection_allowlist_utils.h
|
||||
+++ b/content/browser/connection_allowlist_utils.h
|
||||
@@ -15,6 +15,7 @@ class HttpResponseHeaders;
|
||||
|
||||
namespace content {
|
||||
|
||||
+class RenderFrameHost;
|
||||
struct PolicyContainerPolicies;
|
||||
|
||||
// Returns true if the parsed response headers contains a valid
|
||||
@@ -47,6 +48,12 @@ network::ConnectionAllowlists GetConnectionAllowlistsForWorker(
|
||||
const PolicyContainerPolicies* creator_policies,
|
||||
bool inherit_from_creator);
|
||||
|
||||
+// Determines if the Connection Allowlist for `frame` has allowed WebRTC. This
|
||||
+// is controlled by the Connection Allowlist header's `webrtc` directive, and is
|
||||
+// is used to prevent binding of downstream interfaces like P2PSocketManger and
|
||||
+// MDNSResponder.
|
||||
+bool IsWebRTCAllowedByConnectionAllowlist(RenderFrameHost* frame);
|
||||
+
|
||||
} // namespace content
|
||||
|
||||
#endif // CONTENT_BROWSER_CONNECTION_ALLOWLIST_UTILS_H_
|
||||
diff --git a/content/browser/renderer_host/no_op_mdns_responder.cc b/content/browser/renderer_host/no_op_mdns_responder.cc
|
||||
new file mode 100644
|
||||
index 0000000..d2ff53d
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/no_op_mdns_responder.cc
|
||||
@@ -0,0 +1,38 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#include "content/browser/renderer_host/no_op_mdns_responder.h"
|
||||
+
|
||||
+#include <memory>
|
||||
+#include <string>
|
||||
+#include <utility>
|
||||
+
|
||||
+#include "mojo/public/cpp/bindings/self_owned_receiver.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+// static
|
||||
+void NoOpMdnsResponder::Create(
|
||||
+ mojo::PendingReceiver<network::mojom::MdnsResponder> receiver) {
|
||||
+ mojo::MakeSelfOwnedReceiver(std::make_unique<NoOpMdnsResponder>(),
|
||||
+ std::move(receiver));
|
||||
+}
|
||||
+
|
||||
+NoOpMdnsResponder::NoOpMdnsResponder() = default;
|
||||
+NoOpMdnsResponder::~NoOpMdnsResponder() = default;
|
||||
+
|
||||
+void NoOpMdnsResponder::CreateNameForAddress(
|
||||
+ const net::IPAddress& address,
|
||||
+ CreateNameForAddressCallback callback) {
|
||||
+ std::move(callback).Run(/*name=*/std::string(),
|
||||
+ /*announcement_scheduled=*/false);
|
||||
+}
|
||||
+
|
||||
+void NoOpMdnsResponder::RemoveNameForAddress(
|
||||
+ const net::IPAddress& address,
|
||||
+ RemoveNameForAddressCallback callback) {
|
||||
+ std::move(callback).Run(/*removed=*/false, /*goodbye_scheduled=*/false);
|
||||
+}
|
||||
+
|
||||
+} // namespace content
|
||||
diff --git a/content/browser/renderer_host/no_op_mdns_responder.h b/content/browser/renderer_host/no_op_mdns_responder.h
|
||||
new file mode 100644
|
||||
index 0000000..3f6556b
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/no_op_mdns_responder.h
|
||||
@@ -0,0 +1,39 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#ifndef CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_
|
||||
+#define CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_
|
||||
+
|
||||
+#include "content/common/content_export.h"
|
||||
+#include "mojo/public/cpp/bindings/pending_receiver.h"
|
||||
+#include "services/network/public/mojom/mdns_responder.mojom.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+// An MdnsResponder that never registers or announces any names. It is bound in
|
||||
+// place of the real (network service backed) MdnsResponder for documents whose
|
||||
+// Connection Allowlist blocks WebRTC. All reply callbacks are run with failure
|
||||
+// values so that no renderer request hangs.
|
||||
+class CONTENT_EXPORT NoOpMdnsResponder : public network::mojom::MdnsResponder {
|
||||
+ public:
|
||||
+ // Creates a self-owned NoOpMdnsResponder bound to `receiver`. It is destroyed
|
||||
+ // when the renderer closes the pipe.
|
||||
+ static void Create(
|
||||
+ mojo::PendingReceiver<network::mojom::MdnsResponder> receiver);
|
||||
+
|
||||
+ NoOpMdnsResponder();
|
||||
+ NoOpMdnsResponder(const NoOpMdnsResponder&) = delete;
|
||||
+ NoOpMdnsResponder& operator=(const NoOpMdnsResponder&) = delete;
|
||||
+ ~NoOpMdnsResponder() override;
|
||||
+
|
||||
+ // network::mojom::MdnsResponder:
|
||||
+ void CreateNameForAddress(const net::IPAddress& address,
|
||||
+ CreateNameForAddressCallback callback) override;
|
||||
+ void RemoveNameForAddress(const net::IPAddress& address,
|
||||
+ RemoveNameForAddressCallback callback) override;
|
||||
+};
|
||||
+
|
||||
+} // namespace content
|
||||
+
|
||||
+#endif // CONTENT_BROWSER_RENDERER_HOST_NO_OP_MDNS_RESPONDER_H_
|
||||
diff --git a/content/browser/renderer_host/no_op_mdns_responder_unittest.cc b/content/browser/renderer_host/no_op_mdns_responder_unittest.cc
|
||||
new file mode 100644
|
||||
index 0000000..0a256ad
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/no_op_mdns_responder_unittest.cc
|
||||
@@ -0,0 +1,87 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#include "content/browser/renderer_host/no_op_mdns_responder.h"
|
||||
+
|
||||
+#include <string>
|
||||
+
|
||||
+#include "base/test/task_environment.h"
|
||||
+#include "base/test/test_future.h"
|
||||
+#include "mojo/public/cpp/bindings/remote.h"
|
||||
+#include "net/base/ip_address.h"
|
||||
+#include "services/network/public/mojom/mdns_responder.mojom.h"
|
||||
+#include "testing/gtest/include/gtest/gtest.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+namespace {
|
||||
+
|
||||
+class NoOpMdnsResponderTest : public testing::Test {
|
||||
+ public:
|
||||
+ NoOpMdnsResponderTest() {
|
||||
+ NoOpMdnsResponder::Create(responder_.BindNewPipeAndPassReceiver());
|
||||
+ }
|
||||
+
|
||||
+ protected:
|
||||
+ base::test::TaskEnvironment task_environment_;
|
||||
+ mojo::Remote<network::mojom::MdnsResponder> responder_;
|
||||
+};
|
||||
+
|
||||
+// CreateNameForAddress() must always run its callback, without creating a name
|
||||
+// or scheduling an announcement.
|
||||
+TEST_F(NoOpMdnsResponderTest, CreateNameForAddressFails) {
|
||||
+ base::test::TestFuture<const std::string&, bool> future;
|
||||
+ responder_->CreateNameForAddress(net::IPAddress(192, 168, 0, 1),
|
||||
+ future.GetCallback());
|
||||
+ ASSERT_TRUE(future.Wait());
|
||||
+ EXPECT_TRUE(future.Get<0>().empty());
|
||||
+ EXPECT_FALSE(future.Get<1>());
|
||||
+ EXPECT_TRUE(responder_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// Repeated requests for the same address also fail; no name is ever retained.
|
||||
+TEST_F(NoOpMdnsResponderTest, CreateNameForAddressRepeatedlyFails) {
|
||||
+ const net::IPAddress address(10, 0, 0, 1);
|
||||
+ for (int i = 0; i < 3; ++i) {
|
||||
+ base::test::TestFuture<const std::string&, bool> future;
|
||||
+ responder_->CreateNameForAddress(address, future.GetCallback());
|
||||
+ ASSERT_TRUE(future.Wait());
|
||||
+ EXPECT_TRUE(future.Get<0>().empty());
|
||||
+ EXPECT_FALSE(future.Get<1>());
|
||||
+ }
|
||||
+ EXPECT_TRUE(responder_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// RemoveNameForAddress() must always run its callback, reporting that nothing
|
||||
+// was removed and no goodbye was scheduled.
|
||||
+TEST_F(NoOpMdnsResponderTest, RemoveNameForAddressFails) {
|
||||
+ base::test::TestFuture<bool, bool> future;
|
||||
+ responder_->RemoveNameForAddress(net::IPAddress(192, 168, 0, 1),
|
||||
+ future.GetCallback());
|
||||
+ ASSERT_TRUE(future.Wait());
|
||||
+ EXPECT_FALSE(future.Get<0>());
|
||||
+ EXPECT_FALSE(future.Get<1>());
|
||||
+ EXPECT_TRUE(responder_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// Works for IPv6 addresses too.
|
||||
+TEST_F(NoOpMdnsResponderTest, Ipv6AddressFails) {
|
||||
+ base::test::TestFuture<const std::string&, bool> create_future;
|
||||
+ responder_->CreateNameForAddress(net::IPAddress::IPv6Localhost(),
|
||||
+ create_future.GetCallback());
|
||||
+ ASSERT_TRUE(create_future.Wait());
|
||||
+ EXPECT_TRUE(create_future.Get<0>().empty());
|
||||
+ EXPECT_FALSE(create_future.Get<1>());
|
||||
+
|
||||
+ base::test::TestFuture<bool, bool> remove_future;
|
||||
+ responder_->RemoveNameForAddress(net::IPAddress::IPv6Localhost(),
|
||||
+ remove_future.GetCallback());
|
||||
+ ASSERT_TRUE(remove_future.Wait());
|
||||
+ EXPECT_FALSE(remove_future.Get<0>());
|
||||
+ EXPECT_FALSE(remove_future.Get<1>());
|
||||
+}
|
||||
+
|
||||
+} // namespace
|
||||
+
|
||||
+} // namespace content
|
||||
diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc
|
||||
new file mode 100644
|
||||
index 0000000..b00ee09
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.cc
|
||||
@@ -0,0 +1,57 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h"
|
||||
+
|
||||
+#include <memory>
|
||||
+#include <utility>
|
||||
+
|
||||
+#include "mojo/public/cpp/bindings/self_owned_receiver.h"
|
||||
+#include "net/base/ip_address.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+// static
|
||||
+void NoOpP2PSocketManager::Create(
|
||||
+ mojo::PendingReceiver<network::mojom::P2PSocketManager> receiver) {
|
||||
+ mojo::MakeSelfOwnedReceiver(std::make_unique<NoOpP2PSocketManager>(),
|
||||
+ std::move(receiver));
|
||||
+}
|
||||
+
|
||||
+NoOpP2PSocketManager::NoOpP2PSocketManager() = default;
|
||||
+NoOpP2PSocketManager::~NoOpP2PSocketManager() = default;
|
||||
+
|
||||
+void NoOpP2PSocketManager::StartNetworkNotifications(
|
||||
+ mojo::PendingRemote<network::mojom::P2PNetworkNotificationClient> client) {
|
||||
+ network_notification_client_.reset();
|
||||
+ network_notification_client_.Bind(std::move(client));
|
||||
+ // Report an empty network list so nothing is waiting on network changes.
|
||||
+ network_notification_client_->NetworkListChanged(
|
||||
+ /*networks=*/{}, /*default_ipv4_local_address=*/net::IPAddress(),
|
||||
+ /*default_ipv6_local_address=*/net::IPAddress());
|
||||
+}
|
||||
+
|
||||
+void NoOpP2PSocketManager::GetHostAddress(
|
||||
+ const std::string& host_name,
|
||||
+ std::optional<net::AddressFamily> address_family,
|
||||
+ bool enable_mdns,
|
||||
+ GetHostAddressCallback callback) {
|
||||
+ // Never resolve anything.
|
||||
+ std::move(callback).Run(/*addresses=*/{});
|
||||
+}
|
||||
+
|
||||
+void NoOpP2PSocketManager::CreateSocket(
|
||||
+ network::P2PSocketType type,
|
||||
+ const net::IPEndPoint& local_address,
|
||||
+ const network::P2PPortRange& port_range,
|
||||
+ const network::P2PHostAndIPEndPoint& remote_address,
|
||||
+ const net::MutableNetworkTrafficAnnotationTag& traffic_annotation,
|
||||
+ const std::optional<base::UnguessableToken>& devtools_token,
|
||||
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client,
|
||||
+ mojo::PendingReceiver<network::mojom::P2PSocket> receiver) {
|
||||
+ // Never create a socket. Letting these pending pipes go out of scope should
|
||||
+ // destroy them and treat them as disconnected.
|
||||
+}
|
||||
+
|
||||
+} // namespace content
|
||||
diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h
|
||||
new file mode 100644
|
||||
index 0000000..cf05b8f
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h
|
||||
@@ -0,0 +1,69 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#ifndef CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_
|
||||
+#define CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_
|
||||
+
|
||||
+#include <optional>
|
||||
+#include <string>
|
||||
+
|
||||
+#include "content/common/content_export.h"
|
||||
+#include "mojo/public/cpp/bindings/pending_receiver.h"
|
||||
+#include "mojo/public/cpp/bindings/pending_remote.h"
|
||||
+#include "mojo/public/cpp/bindings/remote.h"
|
||||
+#include "services/network/public/mojom/p2p.mojom.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+// A P2PSocketManager that never performs any networking. It is bound in place
|
||||
+// of the real (network service backed) P2PSocketManager for documents whose
|
||||
+// Connection Allowlist blocks WebRTC.
|
||||
+//
|
||||
+// Binding a no-op implementation (rather than dropping the receiver or leaving
|
||||
+// the interface unregistered) lets an unmodified renderer fail gracefully:
|
||||
+// - The pipe stays connected, so the renderer's P2PSocketDispatcher does not
|
||||
+// enter its reconnect loop.
|
||||
+// - An empty network list is reported, so ICE gathering is allowed to proceed.
|
||||
+// - All reply callbacks are run, so no request hangs.
|
||||
+class CONTENT_EXPORT NoOpP2PSocketManager
|
||||
+ : public network::mojom::P2PSocketManager {
|
||||
+ public:
|
||||
+ // Creates a self-owned NoOpP2PSocketManager bound to `receiver`. It is
|
||||
+ // destroyed when the renderer closes the pipe.
|
||||
+ static void Create(
|
||||
+ mojo::PendingReceiver<network::mojom::P2PSocketManager> receiver);
|
||||
+
|
||||
+ NoOpP2PSocketManager();
|
||||
+ NoOpP2PSocketManager(const NoOpP2PSocketManager&) = delete;
|
||||
+ NoOpP2PSocketManager& operator=(const NoOpP2PSocketManager&) = delete;
|
||||
+ ~NoOpP2PSocketManager() override;
|
||||
+
|
||||
+ // network::mojom::P2PSocketManager:
|
||||
+ void StartNetworkNotifications(
|
||||
+ mojo::PendingRemote<network::mojom::P2PNetworkNotificationClient> client)
|
||||
+ override;
|
||||
+ void GetHostAddress(const std::string& host_name,
|
||||
+ std::optional<net::AddressFamily> address_family,
|
||||
+ bool enable_mdns,
|
||||
+ GetHostAddressCallback callback) override;
|
||||
+ void CreateSocket(
|
||||
+ network::P2PSocketType type,
|
||||
+ const net::IPEndPoint& local_address,
|
||||
+ const network::P2PPortRange& port_range,
|
||||
+ const network::P2PHostAndIPEndPoint& remote_address,
|
||||
+ const net::MutableNetworkTrafficAnnotationTag& traffic_annotation,
|
||||
+ const std::optional<base::UnguessableToken>& devtools_token,
|
||||
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client,
|
||||
+ mojo::PendingReceiver<network::mojom::P2PSocket> receiver) override;
|
||||
+
|
||||
+ private:
|
||||
+ // Kept alive for the lifetime of this object so that the renderer does not
|
||||
+ // observe a disconnect on its notification client.
|
||||
+ mojo::Remote<network::mojom::P2PNetworkNotificationClient>
|
||||
+ network_notification_client_;
|
||||
+};
|
||||
+
|
||||
+} // namespace content
|
||||
+
|
||||
+#endif // CONTENT_BROWSER_RENDERER_HOST_P2P_NO_OP_P2P_SOCKET_MANAGER_H_
|
||||
diff --git a/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc
|
||||
new file mode 100644
|
||||
index 0000000..c83b26e
|
||||
--- /dev/null
|
||||
+++ b/content/browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc
|
||||
@@ -0,0 +1,171 @@
|
||||
+// Copyright 2026 The Chromium Authors
|
||||
+// Use of this source code is governed by a BSD-style license that can be
|
||||
+// found in the LICENSE file.
|
||||
+
|
||||
+#include "content/browser/renderer_host/p2p/no_op_p2p_socket_manager.h"
|
||||
+
|
||||
+#include <optional>
|
||||
+#include <vector>
|
||||
+
|
||||
+#include "base/functional/bind.h"
|
||||
+#include "base/run_loop.h"
|
||||
+#include "base/test/task_environment.h"
|
||||
+#include "base/test/test_future.h"
|
||||
+#include "mojo/public/cpp/bindings/pending_remote.h"
|
||||
+#include "mojo/public/cpp/bindings/receiver.h"
|
||||
+#include "mojo/public/cpp/bindings/remote.h"
|
||||
+#include "net/base/ip_address.h"
|
||||
+#include "net/base/ip_endpoint.h"
|
||||
+#include "net/base/network_interfaces.h"
|
||||
+#include "net/traffic_annotation/network_traffic_annotation_test_helper.h"
|
||||
+#include "services/network/public/cpp/p2p_socket_type.h"
|
||||
+#include "services/network/public/mojom/p2p.mojom.h"
|
||||
+#include "testing/gtest/include/gtest/gtest.h"
|
||||
+
|
||||
+namespace content {
|
||||
+
|
||||
+namespace {
|
||||
+
|
||||
+class FakeNetworkNotificationClient
|
||||
+ : public network::mojom::P2PNetworkNotificationClient {
|
||||
+ public:
|
||||
+ FakeNetworkNotificationClient() = default;
|
||||
+ FakeNetworkNotificationClient(const FakeNetworkNotificationClient&) = delete;
|
||||
+ FakeNetworkNotificationClient& operator=(
|
||||
+ const FakeNetworkNotificationClient&) = delete;
|
||||
+ ~FakeNetworkNotificationClient() override = default;
|
||||
+
|
||||
+ mojo::PendingRemote<network::mojom::P2PNetworkNotificationClient>
|
||||
+ BindNewPipeAndPassRemote() {
|
||||
+ auto remote = receiver_.BindNewPipeAndPassRemote();
|
||||
+ receiver_.set_disconnect_handler(base::BindOnce(
|
||||
+ [](bool* disconnected) { *disconnected = true; }, &disconnected_));
|
||||
+ return remote;
|
||||
+ }
|
||||
+
|
||||
+ bool disconnected() const { return disconnected_; }
|
||||
+
|
||||
+ base::test::TestFuture<std::vector<net::NetworkInterface>,
|
||||
+ net::IPAddress,
|
||||
+ net::IPAddress>&
|
||||
+ network_list_future() {
|
||||
+ return network_list_future_;
|
||||
+ }
|
||||
+
|
||||
+ // network::mojom::P2PNetworkNotificationClient:
|
||||
+ void NetworkListChanged(
|
||||
+ const std::vector<net::NetworkInterface>& networks,
|
||||
+ const net::IPAddress& default_ipv4_local_address,
|
||||
+ const net::IPAddress& default_ipv6_local_address) override {
|
||||
+ network_list_future_.SetValue(networks, default_ipv4_local_address,
|
||||
+ default_ipv6_local_address);
|
||||
+ }
|
||||
+
|
||||
+ private:
|
||||
+ mojo::Receiver<network::mojom::P2PNetworkNotificationClient> receiver_{this};
|
||||
+ base::test::TestFuture<std::vector<net::NetworkInterface>,
|
||||
+ net::IPAddress,
|
||||
+ net::IPAddress>
|
||||
+ network_list_future_;
|
||||
+ bool disconnected_ = false;
|
||||
+};
|
||||
+
|
||||
+class NoOpP2PSocketManagerTest : public testing::Test {
|
||||
+ public:
|
||||
+ NoOpP2PSocketManagerTest() {
|
||||
+ NoOpP2PSocketManager::Create(manager_.BindNewPipeAndPassReceiver());
|
||||
+ }
|
||||
+
|
||||
+ protected:
|
||||
+ base::test::TaskEnvironment task_environment_;
|
||||
+ mojo::Remote<network::mojom::P2PSocketManager> manager_;
|
||||
+};
|
||||
+
|
||||
+// StartNetworkNotifications() must report an (empty) network list so that the
|
||||
+// renderer does not wait forever for one, and must keep the client connected.
|
||||
+TEST_F(NoOpP2PSocketManagerTest, StartNetworkNotificationsReportsEmptyList) {
|
||||
+ FakeNetworkNotificationClient client;
|
||||
+ manager_->StartNetworkNotifications(client.BindNewPipeAndPassRemote());
|
||||
+
|
||||
+ auto& future = client.network_list_future();
|
||||
+ ASSERT_TRUE(future.Wait());
|
||||
+ EXPECT_TRUE(future.Get<0>().empty());
|
||||
+ EXPECT_TRUE(future.Get<1>().empty());
|
||||
+ EXPECT_TRUE(future.Get<2>().empty());
|
||||
+
|
||||
+ // The notification client must remain connected; otherwise the renderer
|
||||
+ // would observe a disconnect.
|
||||
+ manager_.FlushForTesting();
|
||||
+ EXPECT_FALSE(client.disconnected());
|
||||
+ EXPECT_TRUE(manager_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// A second StartNetworkNotifications() call replaces the previous client and
|
||||
+// also receives an empty network list.
|
||||
+TEST_F(NoOpP2PSocketManagerTest, StartNetworkNotificationsTwice) {
|
||||
+ FakeNetworkNotificationClient first_client;
|
||||
+ manager_->StartNetworkNotifications(first_client.BindNewPipeAndPassRemote());
|
||||
+ ASSERT_TRUE(first_client.network_list_future().Wait());
|
||||
+
|
||||
+ FakeNetworkNotificationClient second_client;
|
||||
+ manager_->StartNetworkNotifications(second_client.BindNewPipeAndPassRemote());
|
||||
+ auto& future = second_client.network_list_future();
|
||||
+ ASSERT_TRUE(future.Wait());
|
||||
+ EXPECT_TRUE(future.Get<0>().empty());
|
||||
+
|
||||
+ manager_.FlushForTesting();
|
||||
+ EXPECT_TRUE(first_client.disconnected());
|
||||
+ EXPECT_FALSE(second_client.disconnected());
|
||||
+ EXPECT_TRUE(manager_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// GetHostAddress() must always run its callback, with no addresses.
|
||||
+TEST_F(NoOpP2PSocketManagerTest, GetHostAddressReturnsNoAddresses) {
|
||||
+ base::test::TestFuture<const std::vector<net::IPAddress>&> future;
|
||||
+ manager_->GetHostAddress("example.com", /*address_family=*/std::nullopt,
|
||||
+ /*enable_mdns=*/true, future.GetCallback());
|
||||
+ EXPECT_TRUE(future.Get().empty());
|
||||
+
|
||||
+ // mDNS-style names must not be resolved either.
|
||||
+ base::test::TestFuture<const std::vector<net::IPAddress>&> mdns_future;
|
||||
+ manager_->GetHostAddress("00000000-0000-0000-0000-000000000000.local",
|
||||
+ /*address_family=*/std::nullopt,
|
||||
+ /*enable_mdns=*/true, mdns_future.GetCallback());
|
||||
+ EXPECT_TRUE(mdns_future.Get().empty());
|
||||
+
|
||||
+ EXPECT_TRUE(manager_.is_connected());
|
||||
+}
|
||||
+
|
||||
+// CreateSocket() must never create a socket. The socket pipe is closed, so the
|
||||
+// renderer treats the socket as failed, while the manager stays connected (so
|
||||
+// the renderer does not attempt to reconnect).
|
||||
+TEST_F(NoOpP2PSocketManagerTest, CreateSocketClosesSocketPipe) {
|
||||
+ mojo::PendingReceiver<network::mojom::P2PSocketClient> client_receiver;
|
||||
+ mojo::PendingRemote<network::mojom::P2PSocketClient> client =
|
||||
+ client_receiver.InitWithNewPipeAndPassRemote();
|
||||
+
|
||||
+ mojo::Remote<network::mojom::P2PSocket> socket;
|
||||
+ base::test::TestFuture<void> socket_disconnected;
|
||||
+ auto socket_receiver = socket.BindNewPipeAndPassReceiver();
|
||||
+ socket.set_disconnect_handler(socket_disconnected.GetCallback());
|
||||
+
|
||||
+ manager_->CreateSocket(
|
||||
+ network::P2P_SOCKET_UDP,
|
||||
+ net::IPEndPoint(net::IPAddress::IPv4Localhost(), 0),
|
||||
+ network::P2PPortRange(),
|
||||
+ network::P2PHostAndIPEndPoint(
|
||||
+ "example.com", net::IPEndPoint(net::IPAddress::IPv4Localhost(), 0)),
|
||||
+ net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS),
|
||||
+ /*devtools_token=*/std::nullopt, std::move(client),
|
||||
+ std::move(socket_receiver));
|
||||
+
|
||||
+ EXPECT_TRUE(socket_disconnected.Wait());
|
||||
+ EXPECT_FALSE(socket.is_connected());
|
||||
+
|
||||
+ manager_.FlushForTesting();
|
||||
+ EXPECT_TRUE(manager_.is_connected());
|
||||
+}
|
||||
+
|
||||
+} // namespace
|
||||
+
|
||||
+} // namespace content
|
||||
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
|
||||
index a08ebce..2829cbb 100644
|
||||
--- a/content/browser/renderer_host/render_frame_host_impl.cc
|
||||
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
|
||||
@@ -88,6 +88,10 @@
|
||||
#include "content/browser/can_commit_status.h"
|
||||
#include "content/browser/closewatcher/close_listener_host.h"
|
||||
#include "content/browser/code_cache/generated_code_cache_context.h"
|
||||
+#include "content/browser/connection_allowlist_utils.h"
|
||||
+#if BUILDFLAG(ENABLE_MDNS)
|
||||
+#include "content/browser/renderer_host/no_op_mdns_responder.h"
|
||||
+#endif
|
||||
#include "content/browser/data_url_loader_factory.h"
|
||||
#include "content/browser/declarative_performance_observer/declarative_performance_observer.h"
|
||||
#include "content/browser/devtools/devtools_instrumentation.h"
|
||||
@@ -19515,6 +19519,17 @@ RenderFrameHostImpl::CreateDeviceBoundSessionObserver() {
|
||||
#if BUILDFLAG(ENABLE_MDNS)
|
||||
void RenderFrameHostImpl::CreateMdnsResponder(
|
||||
mojo::PendingReceiver<network::mojom::MdnsResponder> receiver) {
|
||||
+ // If WebRTC is blocked by this document's Connection Allowlist, bind a no-op
|
||||
+ // MdnsResponder so that no names are ever registered or announced, while
|
||||
+ // still replying to every renderer request (see NoOpMdnsResponder).
|
||||
+ // It is possible that a renderer which does not allow WebRTC could still
|
||||
+ // reach this interface. TODO(crbug.com/571239543): Disable all WebRTC
|
||||
+ // connections on the renderer side.
|
||||
+ if (!IsWebRTCAllowedByConnectionAllowlist(this)) {
|
||||
+ NoOpMdnsResponder::Create(std::move(receiver));
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
GetStoragePartition()->GetNetworkContext()->CreateMdnsResponder(
|
||||
std::move(receiver));
|
||||
}
|
||||
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
|
||||
index 7ecab4f..454e0aa 100644
|
||||
--- a/content/test/BUILD.gn
|
||||
+++ b/content/test/BUILD.gn
|
||||
@@ -21,6 +21,7 @@ import("//mojo/public/tools/bindings/mojom.gni")
|
||||
import("//net/features.gni")
|
||||
import("//printing/buildflags/buildflags.gni")
|
||||
import("//services/device/public/cpp/compute_pressure/buildflags.gni")
|
||||
+import("//services/network/public/cpp/features.gni")
|
||||
import("//services/screen_ai/buildflags/features.gni")
|
||||
import("//testing/test.gni")
|
||||
import("//third_party/blink/public/public_features.gni")
|
||||
@@ -3402,6 +3403,16 @@ test("content_unittests") {
|
||||
deps += [ "//content/browser/smart_card/emulation" ]
|
||||
}
|
||||
|
||||
+ if (enable_mdns) {
|
||||
+ sources += [ "../browser/renderer_host/no_op_mdns_responder_unittest.cc" ]
|
||||
+ }
|
||||
+
|
||||
+ if (is_p2p_enabled) {
|
||||
+ sources +=
|
||||
+ [ "../browser/renderer_host/p2p/no_op_p2p_socket_manager_unittest.cc" ]
|
||||
+ deps += [ "//services/network/public/cpp:cpp_p2p" ]
|
||||
+ }
|
||||
+
|
||||
if (enable_nocompile_tests) {
|
||||
deps += [ ":content_nocompile_tests" ]
|
||||
}
|
||||
diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js
|
||||
new file mode 100644
|
||||
index 0000000..cce0fd7
|
||||
--- /dev/null
|
||||
+++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js
|
||||
@@ -0,0 +1,74 @@
|
||||
+// META: script=/common/get-host-info.sub.js
|
||||
+// META: script=/content-security-policy/webrtc/webrtc.js
|
||||
+//
|
||||
+// The following tests assume the policy `Connection-Allowlist:
|
||||
+// (response-origin);webrtc=allow` has been set. Child frames with a local
|
||||
+// scheme (about:blank, about:srcdoc) inherit the parent's policy container,
|
||||
+// and should continue to allow WebRTC, including when nested.
|
||||
+
|
||||
+const WEBRTC_HELPER = '/content-security-policy/webrtc/webrtc.js';
|
||||
+// Loads webrtc.js into `win`'s own document, so `tryConnect` (and the
|
||||
+// RTCPeerConnection it uses) run in the child's realm, not the parent's.
|
||||
+function loadHelper(win) {
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const script = win.document.createElement('script');
|
||||
+ script.src = WEBRTC_HELPER;
|
||||
+ script.onload = resolve;
|
||||
+ script.onerror = () => reject(new Error(`Failed to load ${WEBRTC_HELPER}`));
|
||||
+ win.document.head.appendChild(script);
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+async function tryConnectIn(win) {
|
||||
+ await loadHelper(win);
|
||||
+ return win.tryConnect();
|
||||
+}
|
||||
+
|
||||
+// Appends an iframe with no `src` to `doc`. The frame stays on its initial
|
||||
+// about:blank document, which is usable synchronously.
|
||||
+function addBlankFrame(t, doc) {
|
||||
+ const frame = doc.createElement('iframe');
|
||||
+ t.add_cleanup(() => frame.remove());
|
||||
+ doc.body.appendChild(frame);
|
||||
+ return frame;
|
||||
+}
|
||||
+
|
||||
+// Appends an about:srcdoc iframe to `doc`, and waits for the srcdoc document
|
||||
+// to load (before that, `contentWindow` is still the initial about:blank
|
||||
+// document).
|
||||
+async function addSrcdocFrame(
|
||||
+ t, doc, srcdoc = '<!doctype html><body><p>Hello World</p></body>') {
|
||||
+ const frame = doc.createElement('iframe');
|
||||
+ t.add_cleanup(() => frame.remove());
|
||||
+ const loaded = new Promise(resolve => frame.onload = resolve);
|
||||
+ frame.srcdoc = srcdoc;
|
||||
+ doc.body.appendChild(frame);
|
||||
+ await loaded;
|
||||
+ return frame;
|
||||
+}
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ assert_equals(await tryConnect(), 'allowed');
|
||||
+}, 'WebRTC is allowed in the top-level document.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const frame = addBlankFrame(t, document);
|
||||
+ assert_equals(await tryConnectIn(frame.contentWindow), 'allowed');
|
||||
+}, 'WebRTC is allowed in an about:blank child frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const frame = await addSrcdocFrame(t, document);
|
||||
+ assert_equals(await tryConnectIn(frame.contentWindow), 'allowed');
|
||||
+}, 'WebRTC is allowed in an about:srcdoc child frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const outer = addBlankFrame(t, document);
|
||||
+ const inner = addBlankFrame(t, outer.contentDocument);
|
||||
+ assert_equals(await tryConnectIn(inner.contentWindow), 'allowed');
|
||||
+}, 'WebRTC is allowed in a nested about:blank frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const outer = await addSrcdocFrame(t, document);
|
||||
+ const inner = await addSrcdocFrame(t, outer.contentDocument);
|
||||
+ assert_equals(await tryConnectIn(inner.contentWindow), 'allowed');
|
||||
+}, 'WebRTC is allowed in a nested about:srcdoc frame.');
|
||||
diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers
|
||||
new file mode 100644
|
||||
index 0000000..e8ebc16
|
||||
--- /dev/null
|
||||
+++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-allow.sub.window.js.headers
|
||||
@@ -0,0 +1 @@
|
||||
+Connection-Allowlist: (response-origin);webrtc=allow
|
||||
diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js
|
||||
new file mode 100644
|
||||
index 0000000..e782a57
|
||||
--- /dev/null
|
||||
+++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js
|
||||
@@ -0,0 +1,75 @@
|
||||
+// META: script=/common/get-host-info.sub.js
|
||||
+// META: script=/content-security-policy/webrtc/webrtc.js
|
||||
+//
|
||||
+// The following tests assume the policy `Connection-Allowlist:
|
||||
+// (response-origin)` has been set. WebRTC has been blocked by default. Child
|
||||
+// frames with a local scheme (about:blank, about:srcdoc) inherit the parent's
|
||||
+// policy container, and should continue to block WebRTC, including when
|
||||
+// nested.
|
||||
+
|
||||
+const WEBRTC_HELPER = '/content-security-policy/webrtc/webrtc.js';
|
||||
+// Loads webrtc.js into `win`'s own document, so `tryConnect` (and the
|
||||
+// RTCPeerConnection it uses) run in the child's realm, not the parent's.
|
||||
+function loadHelper(win) {
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const script = win.document.createElement('script');
|
||||
+ script.src = WEBRTC_HELPER;
|
||||
+ script.onload = resolve;
|
||||
+ script.onerror = () => reject(new Error(`Failed to load ${WEBRTC_HELPER}`));
|
||||
+ win.document.head.appendChild(script);
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+async function tryConnectIn(win) {
|
||||
+ await loadHelper(win);
|
||||
+ return win.tryConnect();
|
||||
+}
|
||||
+
|
||||
+// Appends an iframe with no `src` to `doc`. The frame stays on its initial
|
||||
+// about:blank document, which is usable synchronously.
|
||||
+function addBlankFrame(t, doc) {
|
||||
+ const frame = doc.createElement('iframe');
|
||||
+ t.add_cleanup(() => frame.remove());
|
||||
+ doc.body.appendChild(frame);
|
||||
+ return frame;
|
||||
+}
|
||||
+
|
||||
+// Appends an about:srcdoc iframe to `doc`, and waits for the srcdoc document
|
||||
+// to load (before that, `contentWindow` is still the initial about:blank
|
||||
+// document).
|
||||
+async function addSrcdocFrame(
|
||||
+ t, doc, srcdoc = '<!doctype html><body><p>Hello World</p></body>') {
|
||||
+ const frame = doc.createElement('iframe');
|
||||
+ t.add_cleanup(() => frame.remove());
|
||||
+ const loaded = new Promise(resolve => frame.onload = resolve);
|
||||
+ frame.srcdoc = srcdoc;
|
||||
+ doc.body.appendChild(frame);
|
||||
+ await loaded;
|
||||
+ return frame;
|
||||
+}
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ assert_equals(await tryConnect(), 'blocked');
|
||||
+}, 'WebRTC is blocked in the top-level document.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const frame = addBlankFrame(t, document);
|
||||
+ assert_equals(await tryConnectIn(frame.contentWindow), 'blocked');
|
||||
+}, 'WebRTC is blocked in an about:blank child frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const frame = await addSrcdocFrame(t, document);
|
||||
+ assert_equals(await tryConnectIn(frame.contentWindow), 'blocked');
|
||||
+}, 'WebRTC is blocked in an about:srcdoc child frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const outer = addBlankFrame(t, document);
|
||||
+ const inner = addBlankFrame(t, outer.contentDocument);
|
||||
+ assert_equals(await tryConnectIn(inner.contentWindow), 'blocked');
|
||||
+}, 'WebRTC is blocked in a nested about:blank frame.');
|
||||
+
|
||||
+promise_test(async t => {
|
||||
+ const outer = await addSrcdocFrame(t, document);
|
||||
+ const inner = await addSrcdocFrame(t, outer.contentDocument);
|
||||
+ assert_equals(await tryConnectIn(inner.contentWindow), 'blocked');
|
||||
+}, 'WebRTC is blocked in a nested about:srcdoc frame.');
|
||||
diff --git a/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers
|
||||
new file mode 100644
|
||||
index 0000000..0dd9194
|
||||
--- /dev/null
|
||||
+++ b/third_party/blink/web_tests/external/wpt/connection-allowlist/tentative/webrtc-local-scheme-block.sub.window.js.headers
|
||||
@@ -0,0 +1 @@
|
||||
+Connection-Allowlist: (response-origin)
|
||||
\ No newline at end of file
|
||||
@@ -1,7 +1,32 @@
|
||||
# S Browser: Anforderungsabgleich und offene Arbeit
|
||||
|
||||
**Scope-Änderung vom 13. Oktober 2026:** Maßgeblich ist jetzt
|
||||
[das Windows-DLP-MVP-Ziel](windows-dlp-mvp.md). Nur Upload-, Clipboard-/Prompt-DLP,
|
||||
zentrale Durchsetzung und Stabilitäts-/Windows-Abnahme sind aktive Arbeit.
|
||||
Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap.
|
||||
|
||||
## Aktuelle nächste Abnahmen
|
||||
|
||||
Aktueller Integrationsstand: 713 Tests bestehen
|
||||
(`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte
|
||||
Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft:
|
||||
193 abgeschlossene Anfragen mit korrelierten exportierten Traces, eine abgebrochene
|
||||
Leseanfrage, keine fehlgeschlagenen Mutationen. Zentrale Worker-Policy und
|
||||
restriktive Profilsteuerung sind implementiert; die Laufzeitintegration der
|
||||
unveränderlich zugeordneten Agent-Partitionen ist noch in Arbeit. Der letzte
|
||||
geprüfte Windows-Build gehört zum vorangegangenen Visual-Export-Stand und ist
|
||||
kein Paketnachweis für diese neuen Änderungen.
|
||||
|
||||
Unabhängige Registry-Prüfung: Ein synchroner Abbruch innerhalb der
|
||||
Session-Erstellung konnte vor der Registrierung des Abort-Listeners eintreten.
|
||||
Die Anfrage schlug fehl, während die native Sitzung aktiv blieb. Die
|
||||
Laufzeitintegration muss diese Race mit Regressionstest schließen. Ebenso sind
|
||||
begrenzte Revocation-Tombstones und begrenzte Setup-/Cleanup-Wartezeiten zu
|
||||
prüfen. Requests ohne WebContents dürfen weder über den aktiven Tab noch allein
|
||||
über fehlende Initiator-Metadaten eine Identität erhalten. Für interne Downloads
|
||||
ist eine nachweisbar private Transportzuordnung erforderlich; ein allgemeiner
|
||||
ownerloser Request-Ausnahmepfad ist kein akzeptabler Ersatz.
|
||||
|
||||
Gespeicherte Agent-Sitzungen und kontrollierte Credential-Vorbereitung sind für
|
||||
ihre dokumentierten nativen Pfade integriert und nachgewiesen. Die vorangegangene
|
||||
Controlled-Login-Abnahme umfasste 668 bestandene Tests, Syntaxprüfung, stabilen Windows-Build,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Verbindliches Ziel: Windows-DLP-MVP
|
||||
|
||||
Der Nutzer hat am 13. Oktober 2026 den bisherigen breiten Funktionsumfang
|
||||
ausdrücklich ersetzt. Dieses Ziel hat Vorrang vor älteren Aufgabenlisten:
|
||||
Ein stabiler, zentral verwalteter Windows-Browser verhindert, dass vertrauliche
|
||||
Texte oder Dateien durch Uploads, Copy/Paste oder KI-Prompt-Versand an nicht
|
||||
erlaubte Ziele gelangen. Erst nach der folgenden Abnahme wird das MVP abgeschlossen.
|
||||
|
||||
## Abnahmekriterien
|
||||
|
||||
1. Ein Administrator kann vertrauliche Textbeispiele und Referenzdokumente
|
||||
registrieren, Regeln veröffentlichen und erlaubte KI-Ziele konfigurieren.
|
||||
Der Browser erzwingt die signierte aktuelle Policy; Benutzer können den Schutz
|
||||
nicht durch lokale Einstellungen abschalten.
|
||||
2. Paste per Tastatur, Kontextmenü und Seiten-Clipboard wird vor Freigabe geprüft.
|
||||
Copy und Cut werden ebenfalls vor dem Schreiben in die Windows-Zwischenablage
|
||||
geprüft. Jeder Paste liest den aktuellen Inhalt, einschließlich Änderungen
|
||||
durch andere Anwendungen, und gibt ausschließlich den geprüften Snapshot
|
||||
frei. Die zentrale Policy kann vertrauliche Inhalte und gefährliche
|
||||
Ausführungsbefehle sperren. Änderungen von Ziel, Auswahl oder Policy während
|
||||
eines asynchronen Scans dürfen keinen ungeprüften Inhalt freigeben.
|
||||
Erlaubter öffentlicher Text funktioniert in normalen Eingabefeldern und den
|
||||
im MVP getesteten Web-Editoren unabhängig von Website oder Anwendung
|
||||
(Notizen, Webmail, Chat und KI). Vertraulicher Text wird blockiert. Anschließender
|
||||
Versand, getippter Text und durch Seitenskripte erzeugte Request-Bodies werden
|
||||
zusätzlich geprüft; Paste-Prüfung allein reicht nicht.
|
||||
3. Gewöhnliche Windows-Dateiuploads per Dateiauswahl und Drag-and-drop werden
|
||||
anhand der tatsächlich übertragenen Bytes geprüft. Lokale Klassifikation,
|
||||
Fingerprints, semantische Erkennung und erforderliches OCR bleiben lokal.
|
||||
Änderung nach dem Scan darf keine ungeprüften Bytes freigeben.
|
||||
4. Verschlüsselte, übergroße oder unvollständig geprüfte Dateien sowie nicht
|
||||
unterstützte Transferwege werden mit verständlichem Grund blockiert.
|
||||
Fehler, Scanner-Ausfall, abgelaufene Policy und Widerruf dürfen keine
|
||||
Freigabe vertraulicher Inhalte verursachen. Alternative Netzwerkwege dürfen
|
||||
die erzwungene DLP-Prüfung nicht umgehen.
|
||||
5. Echte Windows-/Electron-Tests prüfen für jeden unterstützten Kernweg sowohl
|
||||
erlaubte Übertragung als auch vertrauliche Inhalte. Ein kontrollierter
|
||||
Empfänger beweist, dass blockierte Inhalte nicht angekommen sind. Die Tests
|
||||
schließen Policywechsel, Tab-/Frame-Wechsel und Scannerfehler ein.
|
||||
6. Block-/Allow-Entscheidungen sind für den Administrator nachvollziehbar.
|
||||
Debug-Traces korrelieren Prüfung und Freigabe, zeigen Laufzeiten und Fehler;
|
||||
Passwörter und Tokens gelangen nicht in gewöhnliche Logs. Zentraler
|
||||
Diagnostikschalter verändert keine DLP-Entscheidung.
|
||||
7. Ein reproduzierbarer Windows-Build enthält exakt den geprüften Code und die
|
||||
lokalen Scanner-Runtimes. Enrollment, Policy-Sync, mehrere Tabs, wiederholte
|
||||
Uploads/Paste und Neustart bestehen einen dokumentierten Stabilitätslauf.
|
||||
Keine offene kritische Umgehung oder Regression auf diesen Kernwegen.
|
||||
|
||||
## Aktueller Abnahmestand
|
||||
|
||||
749 Tests bestehen im letzten vollständigen Lauf; ESLint verlangt null Fehler
|
||||
und null Warnungen. Nachweis: `artifacts/mvp-final-startup-suite.log`.
|
||||
Die neue native Windows-Abnahme besteht mit 17 Checks, einschließlich echtem
|
||||
Maus-Klick im contenteditable-Editor, Klartext/HTML-Clipboard, Policy-Race,
|
||||
YARA-/ICAP-Block und Scanner-Ausfall. Danach startete ein anderer Browserprozess
|
||||
mit demselben DPAPI-Profil, Gerät und Policy v4, ohne erneutes Enrollment.
|
||||
Fünf weitere Neustart-Checks bestehen: insgesamt 13 erlaubte Requests,
|
||||
null vertrauliche Requests und zwei exakt übertragene öffentliche Dateien.
|
||||
Beide Prozesse exportierten korrelierte Clipboard-, URL- und Upload-Traces
|
||||
mit null Exportfehlern. Nachweise: `artifacts/windows-dlp-mvp-process-restart-summary.json`,
|
||||
`windows-dlp-mvp-first-process-native-proof.json` und
|
||||
`windows-dlp-mvp-restart-native-proof.json`.
|
||||
Der aktuelle integrierte Relay-/Proxy-/Preload-Stand bestand diese native
|
||||
Abnahme erneut: `artifacts/windows-dlp-mvp-current-frozen-native.log`.
|
||||
|
||||
Das Gateway prüft normalisierte Textdarstellungen einzeln und revalidiert
|
||||
Policy/Widerruf vor Versand und Ausgabe. Tatsächliche ICAP-/Provider-Tests
|
||||
blockieren auch codierte Scanner-Marker vor dem Anbieter. Begrenzte
|
||||
Normalisierung ist kein universeller Deobfuskationsnachweis.
|
||||
|
||||
Ein Windows-Build ist abgeschlossen, einschließlich tatsächlicher
|
||||
Fuse-Werte, eingebettetem ASAR-Header-Hash und Parser-ACL-Provisionierung.
|
||||
Paket-/Runtime-Vergleich bestanden. Der korrigierte CLI-Inspector-Test und die
|
||||
manipulierte ASAR-Kopie wurden danach tatsächlich negativ geprüft; die Kopie
|
||||
wurde vor Anwendungseinstieg abgewiesen. Eine weitere unabhängige Prüfung fand
|
||||
akzeptierte Zertifikats-/Feature-Bypass-Startparameter. Der Quellstand verweigert
|
||||
diese inzwischen mit terminalem, festem Fehler. Der erneute Build, vollständige
|
||||
Paketprüfung und elf zusätzliche negative Startfälle bestehen am tatsächlichen
|
||||
Paket. Nachweis: `artifacts/package-fuses-proof.json`, einschließlich
|
||||
`unsafeStartupOverridesRejected=true`. Der Installer bleibt `NotSigned`.
|
||||
WebTransport ist inzwischen als tatsächlicher Umgehungsweg bestätigt:
|
||||
HTTP-DLP-Hooks und Abschalt-Flags verhinderten die Übertragung nicht. Ein nativer
|
||||
Proxy-Zwang wurde dagegen mit null empfangenen Paketen bei weiter funktionierendem
|
||||
HTTP bewiesen. Der authentifizierte lokale Relay ist inzwischen in Enrollment,
|
||||
Policywechsel und Quarantäne integriert; Browserverkehr erhält keinen DIRECT-
|
||||
Fallback. Enterprise-Proxy-Zugangsdaten sind an Host und Port gebunden.
|
||||
Die vollständige Browser-Abnahme mit echtem WebTransport-Empfänger besteht:
|
||||
Eine ungeschützte Window-/Worker-Kontrolle übertrug 132 Bytes; der tatsächlich
|
||||
enrollte Browser übertrug null Pakete und Bytes, auch nach reviewed DIRECT und
|
||||
HTTPS-PAC→DIRECT-Policywechseln. Drei gewöhnliche HTTPS-POSTs funktionierten.
|
||||
Nachweis: `artifacts/fullmain-webtransport-native-proof.json`, mit genauen
|
||||
Quellhashes; spätere Änderungen betreffen ausschließlich Startparameterprüfung.
|
||||
Die vor HTTP blockierten Versuche liefern keine webRequest-Callbacks und
|
||||
damit weiterhin keine einzelnen DLP-Audit-Ereignisse. Diese SIEM-Grenze bleibt offen.
|
||||
Ältere fehlgeschlagene Testläufe bleiben als solche erhalten.
|
||||
|
||||
## Verbleibende Abnahmen
|
||||
|
||||
Die lokale semantische Browser-Abnahme ist jetzt bestanden: reviewed Teaching
|
||||
behält 36 Fragment-Fingerprints und Prüfmetadaten, keinen Originaltext. Mit
|
||||
dem tatsächlichen gepinnten Modell wurden öffentliche Inhalte erlaubt und eine
|
||||
nichtliteral vertrauliche Paraphrase bei Paste, Textversand und Dateiupload
|
||||
blockiert. Drei öffentliche Requests und null vertrauliche Paraphrasen erreichten
|
||||
den Empfänger; eine öffentliche Datei kam exakt an. Nachweise:
|
||||
`artifacts/windows-semantic-dlp-native-proof.json` und
|
||||
`artifacts/windows-semantic-teaching-proof.json`.
|
||||
|
||||
Neue Stabilitätsgrenze: Chromium 152 kann bei WebRTC in einem initialen
|
||||
about:blank-Iframe unter der nativen Connection-Allowlist den Renderer beenden.
|
||||
Der zusätzliche Frame-Preload verhindert entsprechende Konstruktoraufrufe in
|
||||
srcdoc-, Blob- und Cross-Origin-Frames; initiales about:blank erhält diesen
|
||||
Preload jedoch nicht. Diese verbleibende Grenze ist kein bestandener
|
||||
All-Frame-Nachweis. Eine weitere native oder frühere Absicherung ist vor der
|
||||
entsprechenden Stabilitätsabnahme erforderlich. Der zugehörige Chromium-Fix
|
||||
ist Commit `46fcf3faed9db394de7046a100301f64c2842f0e`; auch die isoliert
|
||||
getestete Electron-Nightly vom 13. Oktober enthält ihn noch nicht. Ein bloßes
|
||||
Versionsupdate oder JavaScript-Wrapper gilt daher nicht als Lösung.
|
||||
Ein kontextangepasster [Backport](native-runtime/README.md) besteht
|
||||
`git apply --check` gegen die genaue Chromium-Basis; er ist noch nicht kompiliert
|
||||
oder nativ abgenommen.
|
||||
|
||||
- Aktuelle native Abnahmen zusammenführen und gegen den aktuellen Quellstand
|
||||
erneut prüfen; ältere grüne Tests belegen keinen später geänderten Build.
|
||||
- Praktische Paste-/Versand-Coverage weiterer moderner Editoren prüfen.
|
||||
Einfache contenteditable-Textpfade sind nativ mit erlaubtem Text, vertraulichem
|
||||
Text und inert eingefügtem HTML geprüft. Komplexer Richtext und undurchsichtige
|
||||
Clipboard-Formate bleiben konservativ gesperrt; universelle Editor-Kompatibilität
|
||||
ist damit nicht belegt.
|
||||
- Nicht unterstützte Hintergrund-/Streaming-/WebSocket-/WebRTC-Pfade auf
|
||||
tatsächliche Sperre prüfen; umfassende Worker-Unterstützung ist kein MVP-Ziel.
|
||||
- Race-, Timeout-, Policywechsel- und Scannerfehler-Nachweise vervollständigen.
|
||||
- Abschließende vollständige Tests, native DLP-Abnahme, Stabilitätslauf,
|
||||
Windows-Build und Paketprüfung durchführen und Ergebnisse dokumentieren.
|
||||
|
||||
Die Liste ist ein Arbeitsauftrag, keine Behauptung, dass jeder Punkt bereits
|
||||
fehlt oder ein bestätigter Bypass besteht. Der folgende Audit bestimmt die
|
||||
konkreten Defekte und fehlenden Nachweise.
|
||||
|
||||
## Verschoben
|
||||
|
||||
## WebSocket- und WebRTC-Ziel
|
||||
|
||||
Nutzerpräzisierung: WebSockets und WebRTC sollen nutzbar sein, wenn ihr jeweiliger
|
||||
Datenweg überprüfbar ist. Pauschale Sperre ist eine vorläufige Schutzmaßnahme,
|
||||
kein funktionales Endziel. Freigabe muss pro überprüfbarem Kanal erfolgen:
|
||||
WebSocket-Nachrichten einschließlich binärer/komprimierter Nutzlasten und
|
||||
WebRTC-DataChannels benötigen Inhaltsprüfung vor Übertragung sowie aktuelle
|
||||
Policy- und Zielbindung. WebRTC-Audio/Video benötigen eigene Medienregeln und
|
||||
Prüfnachweise; eine Signalisierungs- oder Domainprüfung belegt keine Inhalts-DLP.
|
||||
|
||||
Die vorhandenen Electron-webRequest-Hooks prüfen HTTP-Anfragen und den
|
||||
WebSocket-Verbindungsaufbau, liefern jedoch keinen allgemeinen nativen Gate für
|
||||
jede WebSocket-Nachricht oder WebRTC-Nutzlast. Ein JavaScript-Wrapper allein ist
|
||||
keine belastbare Sicherheitsgrenze. Unterstützte Wege benötigen einen
|
||||
kontrollierten Transport oder zusätzliche native Chromium-Enforcement-Hooks
|
||||
mit echten Nachrichten-/Empfänger- und Umgehungstests. Bis dahin bleibt nur der
|
||||
nicht überprüfbare Weg gesperrt, mit dokumentiertem Kompatibilitätsgrund.
|
||||
|
||||
## Weitere Roadmap
|
||||
|
||||
Andere Betriebssysteme und Mobile, vollständige KI-Agentensteuerung und eigene
|
||||
Worker-Sitzungen, weitere Komfort-/Designfunktionen, zusätzliche Vault-Funktionen,
|
||||
breitere Multimediaanalyse und zusätzliche Admin-Erweiterungen sind Roadmap.
|
||||
Bestehender Code bleibt erhalten, wird aber nicht zur Voraussetzung dieser
|
||||
MVP-Abnahme erweitert.
|
||||
|
||||
Produktionsrollout bleibt von Pilot-/MVP-Abnahme getrennt: signierter Installer
|
||||
und Updatekanal, unabhängige Sicherheitsprüfung sowie kundenspezifische IdP-,
|
||||
Proxy-/ICAP-/SIEM-Integration benötigen eigene Nachweise. Das MVP verspricht
|
||||
keine universelle Erkennung aller Geheimnisse und keinen Schutz gegen einen
|
||||
Angreifer mit lokalen Administratorrechten.
|
||||
+39
-17
@@ -1,25 +1,47 @@
|
||||
# Native worker ownership: verified boundary and next implementation
|
||||
# Attributed agent background workers
|
||||
|
||||
Current individual-agent mode blocks requests without a guarded native tab owner. This deliberately prevents service workers from borrowing an actor's authority through origin, Referer, renderer messages or a guessed tab. It also limits compatibility with worker-dependent applications.
|
||||
The central `browser.allowAgentBackgroundWorkers` gate defaults false. The
|
||||
permission-only `network:background` scope is also explicit in both the agent
|
||||
baseline and individual grant; it is not a callable agent RPC. User profiles can
|
||||
inherit or deny this feature (`features.agentBackgroundWorkers`); they cannot
|
||||
allow it beyond baseline or grant. Applicable denials accumulate.
|
||||
|
||||
## Native evidence
|
||||
Development support is restricted to immutable, nonpersistent native browser
|
||||
partitions owned by the enrolled device, individual principal and credential
|
||||
generation. Human/default shared partitions, legacy callers and unattributed
|
||||
worker traffic remain blocked. Enabling this feature is not general human browser
|
||||
worker compatibility, persistence or cross-device worker roaming.
|
||||
|
||||
A local Windows feasibility experiment used Electron 44.7.0 / Chromium 152.0.7977.130, two independent in-memory native sessions, and the same synthetic loopback origin. Each session registered a service worker and initiated a POST from that worker. Each session's native webRequest listener received only its own request.
|
||||
Before network release the owner registry must prove the worker's partition,
|
||||
registration/source and native context; current policy, grant generation/expiry,
|
||||
destination and DLP remain mandatory. The foreground tab or a renderer-provided
|
||||
identity cannot establish ownership. Revocation must deny existing worker traffic,
|
||||
not merely prevent new registrations. No cloud inspection or extra worker scope
|
||||
bypasses local mandatory inspection.
|
||||
|
||||
The worker requests had no webContentsId or requesting frame. Both had the same initiator origin. Their request objects carried no worker version ID or process ID. ServiceWorkers.getAllRunning exposed different renderProcessIds, but those identifiers were absent from webRequest events. Runtime worker version IDs were available as dictionary keys; the worker information values omitted the versionId field advertised in the installed type definition.
|
||||
## Verification status
|
||||
|
||||
Local ignored evidence: `artifacts/worker-partition-feasibility.json` and `.log`. This isolated API experiment did not run production browser guards or establish an OpenTelemetry integration proof.
|
||||
The current real Electron admin workflow exercised typed login-rule editing,
|
||||
independent signed publication and auditor read-only access. All 193 completed
|
||||
API requests had exported traces; one canceled read had no failed mutation.
|
||||
The collector reported zero rejected writes. Trace examples:
|
||||
`a0f881d3c99650c53bacf547a191b216` and
|
||||
`4416533c41ffe3e2adb81af46a30e0d4`.
|
||||
|
||||
Electron documents native [webRequest events](https://www.electronjs.org/docs/latest/api/web-request), [service-worker inventory](https://www.electronjs.org/docs/latest/api/service-workers), and [session partitions](https://www.electronjs.org/docs/latest/api/session). The installed `node_modules/electron/electron.d.ts` was checked against the actual runtime.
|
||||
Focused policy tests verify the disabled default, preservation through signed
|
||||
publication, strict boolean validation and rejection of `network:background`
|
||||
as an executable RPC. Eleven registry tests cover immutable ownership,
|
||||
installation-before-network-use, revocation and retryable failed cleanup.
|
||||
|
||||
## Defensible design
|
||||
The central gate/scope controls start unchecked, and profile schema/enforcement
|
||||
prove inherit/deny-only behavior. The actual Windows/Electron admin fixture also verifies the worker gate/scope
|
||||
start off, auditor controls are disabled, and typed login-rule creation goes
|
||||
through exact signed independent review and policy-history restoration. Its 193
|
||||
completed API requests export parent-correlated spans; one canceled GET during
|
||||
identity reload is recorded separately, with no failed mutation. See
|
||||
`artifacts/agent-grants-history-admin-proof.json`,
|
||||
`artifacts/agent-grants-history-admin-traces.json` and
|
||||
`artifacts/credential-submission-admin-controls.png`.
|
||||
|
||||
Allocate a separate native session for each immutable device/principal/generation identity. Assign ownership in the main process before creating any native tab or initiating a request. Install the full network, DLP, download, permission, proxy, DNS and preload controls before the partition is used. A worker's owning principal then comes from the listener's native session closure, not page-supplied claims.
|
||||
|
||||
Never reassign a partition to another actor or newer generation. Check the current signed grant, device status, policy expiry and destination scopes before inspection, after every awaited stage and immediately before release. Revocation must quarantine the owner's partition and close its connections. Cookies and authentication state should remain isolated unless a separate explicitly authorized sharing design is implemented.
|
||||
|
||||
This attributes requests to a principal, not to a specific tab. Shared workers can serve multiple tabs, and service workers may outlive their clients. Any operation requiring an unavailable tab, exact document or visual approval must remain blocked until an independently verified context is available. Worker downloads, immutable streamed request-body inspection, background lifetimes, restart ownership and complete revocation behavior need native proofs.
|
||||
|
||||
## Migration surface
|
||||
|
||||
The current main process uses one `browserSession` for proxy updates, DNS resolution, permission handlers, upload terminal events, download fetching and will-download, webRequest gates, developer-tools frontends, preload registration, native tab construction, revocation cleanup and VPN quarantine. All relevant uses must become session-aware together. Merely selecting a new session for a tab while leaving these controls global would create an enforcement gap.
|
||||
Runtime owner-registry migration, actual native worker attribution/revocation
|
||||
tests, runtime traces and final Windows/package proof are pending. Do not claim worker support solely from a stored policy flag.
|
||||
@@ -0,0 +1,20 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
|
||||
// Classic scripts listed in the admin HTML share this explicit application API.
|
||||
const adminApi=Object.fromEntries(['$','addBookmark','agentScopes','api','applyGroupPermissions','applyIncidentPermissions','applyOperatorPermissions','applyProfilePermissions','cell','draft','fill','groupChecks','groupSelected','incidentEvidenceViewer','lines','load','loadIncidents','loadPolicyGroups','loadUserProfiles','managedSections','openIncident','policyMutationStatus','renderBookmarkRows','task','updateDraftStatus'].map(name=>[name,'readonly']));
|
||||
|
||||
// The runtime is deliberately split: trusted Node/Electron code and browser
|
||||
// renderers must not accidentally borrow one another's ambient capabilities.
|
||||
export default [
|
||||
{ignores:['node_modules/**','.runtime/**','dist/**','data/**','artifacts/**','docs/media/**']},
|
||||
{...js.configs.recommended,files:['apps/**/*.{js,mjs,cjs}','packages/**/*.{js,mjs,cjs}','scripts/**/*.{js,mjs,cjs}','tests/**/*.{js,mjs,cjs}','eslint.config.mjs'],languageOptions:{ecmaVersion:'latest',globals:globals.node},rules:{...js.configs.recommended.rules,'no-empty':['error',{allowEmptyCatch:true}],'no-unused-vars':['warn',{args:'after-used',argsIgnorePattern:'^_',caughtErrors:'none'}]}},
|
||||
{files:['apps/browser/renderer.js','apps/server/public/**/*.js'],languageOptions:{sourceType:'script',globals:{...globals.browser,process:'off',require:'off',module:'off',Buffer:'off',__dirname:'off',__filename:'off'}}},
|
||||
{files:['apps/server/public/**/*.js'],languageOptions:{globals:{...adminApi,credential:'writable',currentPrincipal:'writable',independentApprovalRequired:'writable',policy:'writable',publishedSnapshot:'writable',status:'writable',fill:'writable',draft:'writable',load:'writable',applyOperatorPermissions:'writable'}},rules:{'no-redeclare':['error',{builtinGlobals:false}]}},
|
||||
{files:['apps/server/public/controlled-login-admin.js','apps/server/public/evidence-viewer.js','apps/server/public/audit-integrity-admin.js'],languageOptions:{globals:{module:'readonly'}}},
|
||||
// These fixed DOM functions are serialized into isolated Chromium worlds.
|
||||
// Their containing module still needs Node globals to export the helper.
|
||||
{files:['apps/browser/*-dom.cjs','apps/browser/*-script.cjs','apps/browser/clipboard-deny.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-visual-probe.cjs','apps/browser/human-interaction.cjs','apps/browser/password-fill.cjs','apps/browser/site-preload.cjs','apps/browser/visual-phishing.cjs'],languageOptions:{globals:globals.browser}},
|
||||
{files:['apps/browser/transport-deny.cjs'],languageOptions:{globals:globals.browser}},
|
||||
{files:['**/*.cjs'],languageOptions:{sourceType:'commonjs'}}
|
||||
];
|
||||
Generated
+888
-23
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -17,7 +17,8 @@
|
||||
"verify:package": "node scripts/verify-package.mjs",
|
||||
"preview:ui": "node scripts/ui-showcase.cjs",
|
||||
"runtime:win": "python scripts/provision-windows-runtime.py",
|
||||
"runtime:semantic": "python scripts/provision-semantic-runtime.py"
|
||||
"runtime:semantic": "python scripts/provision-semantic-runtime.py",
|
||||
"lint": "eslint apps packages scripts tests eslint.config.mjs --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
@@ -37,8 +38,12 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/fuses": "2.1.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"electron": "44.7.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"eslint": "10.12.0",
|
||||
"globals": "17.13.0",
|
||||
"lucide-static": "1.55.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const application=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const AGENT_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(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AGENT_SCOPES=Object.freeze({'network:background':'network:background',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(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{
|
||||
if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'});
|
||||
if(AGENT_SCOPES[value.action]!==value.scope)ctx.addIssue({code:'custom',message:'Agent action scope mismatch'});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const BookmarkSchema=z.object({id:z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/),title:z.string().trim().min(1).max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().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;}},'Bookmarks require HTTP(S) without credentials, query or fragment').transform(v=>new URL(v).href).refine(v=>v.length<=2048,'Canonical bookmark URL exceeds limit'),folder:z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)).default('')}).strict();
|
||||
export const BookmarkItemsSchema=z.array(BookmarkSchema).max(100).refine(items=>new Set(items.map(i=>i.id)).size===items.length,'Bookmark IDs must be unique').refine(items=>Buffer.byteLength(JSON.stringify(items))<=250000,'Bookmark collection exceeds byte budget');
|
||||
export const PersonalBookmarksSchema=z.object({revision:z.number().int().nonnegative().safe().default(0),items:BookmarkItemsSchema.default([])}).strict().prefault({});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export function credentialActionUrlAllowed(value){
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
try {const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&!value.includes('?')&&!value.includes('#')&&u.href===value&&!/[\u0000-\u0020\u007f]/.test(value);}catch{return false;}
|
||||
}
|
||||
const field=z.string().min(1).max(128).regex(/^[\x21-\x7e]+$/,'Credential field names require bounded printable ASCII');
|
||||
|
||||
@@ -4,6 +4,7 @@ const path=require('node:path');
|
||||
const TYPES=['pe','elf','macho','pdf','zip','ole','png','jpeg','gif','webp','gzip','text','unknown'];
|
||||
const EXT={exe:'pe',dll:'pe',sys:'pe',elf:'elf',pdf:'pdf',zip:'zip',docx:'zip',xlsx:'zip',pptx:'zip',docm:'zip',xlsm:'zip',xlsb:'zip',pptm:'zip',dotm:'zip',xlam:'zip',ppam:'zip',doc:'ole',xls:'ole',ppt:'ole',png:'png',jpg:'jpeg',jpeg:'jpeg',gif:'gif',webp:'webp',gz:'gzip',gzip:'gzip',txt:'text',csv:'text',json:'text',html:'text',js:'text',css:'text',xml:'text',md:'text'};
|
||||
const MIME={'application/msword':'ole','application/vnd.ms-excel':'ole','application/vnd.ms-powerpoint':'ole','application/vnd.ms-word.document.macroenabled.12':'zip','application/vnd.ms-excel.sheet.macroenabled.12':'zip','application/vnd.ms-powerpoint.presentation.macroenabled.12':'zip','application/pdf':'pdf','application/zip':'zip','application/x-zip-compressed':'zip','application/vnd.openxmlformats-officedocument.wordprocessingml.document':'zip','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':'zip','application/vnd.openxmlformats-officedocument.presentationml.presentation':'zip','image/png':'png','image/jpeg':'jpeg','image/gif':'gif','image/webp':'webp','application/gzip':'gzip','application/x-gzip':'gzip','application/json':'text','application/xml':'text','application/javascript':'text','application/x-msdownload':'pe','application/vnd.microsoft.portable-executable':'pe'};
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
function safeFilename(value){const name=path.win32.basename(path.posix.basename(String(value||'download.bin'))).replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g,'_').replace(/[. ]+$/g,'').slice(0,180);return !name||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)?'download.bin':name;}
|
||||
function detectType(value){
|
||||
const b=Buffer.from(value);const starts=(hex)=>b.length>=hex.length/2&&b.subarray(0,hex.length/2).equals(Buffer.from(hex,'hex'));
|
||||
@@ -15,6 +16,7 @@ function detectType(value){
|
||||
if(b.subarray(0,6).toString('ascii')==='GIF87a'||b.subarray(0,6).toString('ascii')==='GIF89a')return 'gif';
|
||||
if(b.length>=12&&b.subarray(0,4).toString('ascii')==='RIFF'&&b.subarray(8,12).toString('ascii')==='WEBP')return 'webp';if(starts('1f8b'))return 'gzip';
|
||||
// Decode the entire bounded payload. Prefix-only heuristics miss binary tails.
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
try{const text=new TextDecoder('utf-8',{fatal:true}).decode(b);if(!/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text))return 'text';}catch{}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {z} from 'zod';
|
||||
import {createHash} from 'node:crypto';
|
||||
|
||||
const id=z.string().min(1).max(80).regex(/^[a-zA-Z0-9_-]+$/);
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
const label=z.string().trim().max(80).refine(v=>!/[\u0000-\u001f\u007f]/.test(v));
|
||||
export const LibraryPolicySchema=z.object({aiSuggestionsEnabled:z.boolean().default(false),aiAutoApplyEnabled:z.boolean().default(false)}).strict().prefault({});
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const LibraryTabsSchema=z.array(z.object({id,title:z.string().max(120).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),url:z.string().max(2048).url().refine(v=>['https:','http:'].includes(new URL(v).protocol))}).strict()).max(100).refine(v=>new Set(v.map(t=>t.id)).size===v.length);
|
||||
export const LibraryPlanSchema=z.object({bookmarks:z.array(z.object({id,folder:label}).strict()).max(100),tabs:z.array(z.object({id,group:label.refine(v=>v.length<=40)}).strict()).max(100)}).strict().superRefine((v,c)=>{for(const key of ['bookmarks','tabs'])if(new Set(v[key].map(i=>i.id)).size!==v[key].length)c.addIssue({code:'custom',message:'Duplicate organization IDs'});});
|
||||
export function librarySnapshotHash(tabs){return createHash('sha256').update(JSON.stringify(LibraryTabsSchema.parse(tabs))).digest('hex');}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const GroupRestrictionsSchema=z.object({
|
||||
lockedSections:unique(z.enum(SETTINGS_SECTIONS),SETTINGS_SECTIONS.length).default([]),
|
||||
managedBookmarks:BookmarkItemsSchema.default([])
|
||||
}).strict();
|
||||
// eslint-disable-next-line no-control-regex -- Explicitly reject control characters in security-sensitive input.
|
||||
export const PolicyGroupSchema=z.object({id,name:z.string().min(1).max(100).refine(v=>!/[\u0000-\u001f\u007f]/.test(v)),
|
||||
memberDeviceIds:unique(id,10000),restrictions:GroupRestrictionsSchema,
|
||||
rollout:z.object({stage:z.enum(['pilot','expanded','all']),deviceIds:unique(id,10000).default([])}).strict()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {UserAccessSchema} from './user-profiles.mjs';
|
||||
import {LibraryPolicySchema} from './library.mjs';
|
||||
import {BrandSchema,StartPageSchema} from './branding.mjs';
|
||||
import {EvidencePolicySchema} from './evidence.mjs';
|
||||
import {inspectionTextVariants} from './text-variants.mjs';
|
||||
|
||||
const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const PolicySchema = z.object({
|
||||
@@ -29,6 +30,7 @@ export const PolicySchema = z.object({
|
||||
browser: z.object({
|
||||
homeUrl: z.string().url(), allowedHosts: z.array(host).max(200), blockedHosts: z.array(host).max(200),
|
||||
allowDownloads: z.boolean(), allowDevTools: z.boolean(), allowWebSockets: z.boolean(),
|
||||
allowAgentBackgroundWorkers: z.boolean().default(false),
|
||||
settings: ManagedSettingsSchema, bookmarks: BookmarksSchema
|
||||
}),
|
||||
proxy: z.discriminatedUnion('mode', [
|
||||
@@ -103,9 +105,9 @@ export function inspect(bytes, policy) {
|
||||
try { text = new TextDecoder('utf-8',{fatal:true}).decode(data); }
|
||||
catch { return {decision:'block',ruleId:'uninspectable'}; }
|
||||
if(text.includes('\0')) return {decision:'block',ruleId:'uninspectable'};
|
||||
const variants = [text];
|
||||
try { variants.push(decodeURIComponent(text.replace(/\+/g,' '))); } catch {}
|
||||
try { variants.push(JSON.stringify(JSON.parse(text))); } catch {}
|
||||
let variants;
|
||||
try{variants=inspectionTextVariants(text,policy.dlp.maxBytes);}
|
||||
catch{return {decision:'block',ruleId:'normalization-limit'};}
|
||||
for(const value of variants) {
|
||||
let fragments;for(const r of policy.dlp.rules)if(r.kind==='fragment-sha256'){fragments??=fragmentHashes(value);if(fragments.has(r.value.toLowerCase()))return {decision:'block',ruleId:r.id};}
|
||||
for(const phrase of policy.security?.content?.blockedPhrases||[])if(value.normalize('NFKC').toLowerCase().includes(phrase.normalize('NFKC').toLowerCase()))return {decision:'block',ruleId:'content-blocklist'};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { isIP } from 'node:net';
|
||||
export const AgentScopes=['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','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'];
|
||||
export const AgentScopes=['network:background','vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','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'];
|
||||
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin');
|
||||
export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Bounded decoding for common wire encodings. This is not arbitrary deobfuscation.
|
||||
export function inspectionTextVariants(text,maxBytes){
|
||||
const values=[],seen=new Set();let size=0,candidates=0;
|
||||
const budget=Math.min(maxBytes*4,16*1024*1024);
|
||||
function add(value,depth){
|
||||
if(typeof value!=='string'||seen.has(value))return;
|
||||
if(depth>3)throw Error('Inspection normalization limit');
|
||||
const bytes=Buffer.byteLength(value);
|
||||
if(values.length>=64||bytes>maxBytes||size+bytes>budget)throw Error('Inspection normalization limit');
|
||||
if(value.includes('\0'))return;
|
||||
seen.add(value);size+=bytes;values.push({value,depth});
|
||||
}
|
||||
add(text,0);
|
||||
for(let i=0;i<values.length;i++){
|
||||
const {value,depth}=values[i];
|
||||
try{add(decodeURIComponent(value.replace(/\+/g,' ')),depth+1);}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
try{const parsed=JSON.parse(value);const queue=[parsed];let nodes=0;
|
||||
while(queue.length){if(++nodes>4096)throw Error('Inspection normalization limit');const node=queue.pop();
|
||||
if(typeof node==='string')add(node,depth+1);
|
||||
else if(node&&typeof node==='object'){
|
||||
const children=Object.values(node);
|
||||
if(nodes+queue.length+children.length>4096)throw Error('Inspection normalization limit');
|
||||
for(const child of children)queue.push(child);
|
||||
}
|
||||
}
|
||||
}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
for(const match of value.matchAll(/[A-Za-z0-9+/_-]{12,}={0,2}/g)){
|
||||
const encoded=match[0].replace(/-/g,'+').replace(/_/g,'/'),raw=Buffer.from(encoded,'base64');
|
||||
if(raw.toString('base64').replace(/=+$/,'')!==encoded.replace(/=+$/,''))continue;
|
||||
try{const decoded=new TextDecoder('utf-8',{fatal:true}).decode(raw);
|
||||
if(decoded.includes('\0'))continue;
|
||||
if(++candidates>256)throw Error('Inspection normalization limit');
|
||||
add(decoded,depth+1);
|
||||
}catch(error){if(error.message==='Inspection normalization limit')throw error;}
|
||||
}
|
||||
}
|
||||
return values.map(row=>row.value);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const unique=(schema,max)=>z.array(schema).max(max).refine(a=>new Set(a).size===
|
||||
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const UserAccessSchema=z.object({delegatableFeatures:z.object({devTools:z.boolean().default(false)}).strict().prefault({})}).strict().prefault({});
|
||||
export const DomainCategorySchema=z.object({id,name:z.string().min(1).max(100),hosts:unique(host,500)}).strict();
|
||||
export const UserProfileSchema=z.object({id,name:z.string().min(1).max(100),subjects:unique(z.string().min(1).max(512),10000).default([]),deviceIds:unique(id,10000).default([]),allowedHosts:unique(host,500).default([]),blockedHosts:unique(host,500).default([]),blockedCategoryIds:unique(id,100).default([]),features:z.object({devTools:z.enum(['inherit','allow','deny']).default('inherit'),downloads:z.enum(['inherit','deny']).default('inherit'),ai:z.enum(['inherit','deny']).default('inherit'),agents:z.enum(['inherit','deny']).default('inherit')}).strict().prefault({}),requireDocumentScan:z.boolean().default(false)}).strict();
|
||||
export const UserProfileSchema=z.object({id,name:z.string().min(1).max(100),subjects:unique(z.string().min(1).max(512),10000).default([]),deviceIds:unique(id,10000).default([]),allowedHosts:unique(host,500).default([]),blockedHosts:unique(host,500).default([]),blockedCategoryIds:unique(id,100).default([]),features:z.object({devTools:z.enum(['inherit','allow','deny']).default('inherit'),downloads:z.enum(['inherit','deny']).default('inherit'),ai:z.enum(['inherit','deny']).default('inherit'),agents:z.enum(['inherit','deny']).default('inherit'),agentBackgroundWorkers:z.enum(['inherit','deny']).default('inherit')}).strict().prefault({}),requireDocumentScan:z.boolean().default(false)}).strict();
|
||||
export const UserProfileConfigurationSchema=z.object({profiles:z.array(UserProfileSchema).max(100).default([]),categories:z.array(DomainCategorySchema).max(100).default([])}).strict();
|
||||
export function profileApplies(profile,device){return profile.deviceIds.includes(device.id)||!!device.verified_subject&&profile.subjects.includes(device.verified_subject);}
|
||||
const covers=(a,b)=>a===b||a.startsWith('*.')&&b.endsWith(a.slice(1));
|
||||
@@ -18,6 +18,7 @@ export function applyUserProfiles(policy,configuration,device){
|
||||
if(p.features.downloads==='deny')result.browser.allowDownloads=false;
|
||||
if(p.features.ai==='deny')result.ai.enabled=false;
|
||||
if(p.features.agents==='deny')result.agents.enabled=false;
|
||||
if(p.features.agentBackgroundWorkers==='deny')result.browser.allowAgentBackgroundWorkers=false;
|
||||
if(p.requireDocumentScan)result.inspection.documentAnalysisEnabled=true;
|
||||
}
|
||||
if(profiles.some(p=>p.features.devTools==='deny'))result.browser.allowDevTools=false;
|
||||
|
||||
@@ -8,12 +8,12 @@ async function capture(file,section){await evaluate(`document.getElementById('${
|
||||
app.whenReady().then(async()=>{
|
||||
window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}});
|
||||
const proofTraceIds=[],requestsById=new Map();session.defaultSession.webRequest.onCompleted({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row){row.outcome='completed';row.statusCode=details.statusCode;}});session.defaultSession.webRequest.onErrorOccurred({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row)row.outcome='failed';});session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');const path=new URL(details.url).pathname,route=path==='/api/admin/policy'?'/api/admin/policy':path==='/api/admin/policy/restore'?'/api/admin/policy/restore':/^\/api\/admin\/policy-reviews\/[^/]+\/approve$/.test(path)?'/api/admin/policy-reviews/:id/approve':/^\/api\/admin\/policy-history\/[^/]+\/restore$/.test(path)?'/api/admin/policy-history/:id/restore':'other-api';const row={traceId,parentSpanId:spanId,outcome:'pending',method:details.method,route};proofTraceIds.push(row);requestsById.set(details.id,row);callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});});
|
||||
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});assert.equal(await evaluate(`document.getElementById('vault-controlled-submit').checked`),false);await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('vault-controlled-submit').checked=true;document.getElementById('vault-submit-forms').value=JSON.stringify([{id:'company-login',actionUrl:'https://login.company.example/signin',usernameField:'username',passwordField:'password'}]);document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`);
|
||||
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});assert.equal(await evaluate(`document.getElementById('vault-controlled-submit').checked`),false);assert.equal(await evaluate(`document.getElementById('agent-background-workers').checked||document.getElementById('agent-scope-network:background').checked`),false);await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('vault-controlled-submit').checked=true;document.getElementById('vault-rule-add').click();for(const [key,value]of Object.entries({id:'company-login',actionUrl:'https://login.company.example/signin',usernameField:'username',passwordField:'password'})){const field=document.querySelector('#vault-rule-editor [data-credential-rule-field='+key+']');field.value=value;field.dispatchEvent(new Event('input'));}document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`);
|
||||
assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true);assert.equal(await evaluate(`document.getElementById('agent-sessions-help').textContent.includes('no passwords, form values or page state')&&document.getElementById('agent-sessions-help').textContent.includes('never restores revoked agent authority')`),true);
|
||||
await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);
|
||||
assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403);
|
||||
await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','controlledSubmission','company-login','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2);
|
||||
await capture('artifacts/credential-submission-admin-controls.png','password-vault');await identity('grant-auditor');for(const id of ['vault-controlled-submit','vault-submit-forms'])assert.equal(await evaluate(`document.getElementById('${id}').disabled`),true);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true);
|
||||
await capture('artifacts/credential-submission-admin-controls.png','password-vault');await identity('grant-auditor');assert.equal(await evaluate(`Array.from(document.querySelectorAll('#vault-rule-editor input,#vault-rule-editor button')).every(x=>x.disabled)`),true);for(const id of ['vault-controlled-submit','vault-submit-forms','vault-rule-add','agent-background-workers','agent-scope-network:background'])assert.equal(await evaluate(`document.getElementById('${id}').disabled`),true);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit','screenshot'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true);
|
||||
await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3);
|
||||
await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4);
|
||||
await identity('grant-author');assert.equal(await evaluate(`document.getElementById('agent-scope-screenshot').checked`),true);assert.equal(await evaluate(`document.getElementById('agent-visual-export-help').textContent.includes('does not authorize desktop')`),true);await evaluate(`document.getElementById('agent-scope-screenshot').checked=false;document.getElementById('agent-scope-screenshot').dispatchEvent(new Event('input'));for(const label of document.querySelectorAll('#agent-grants-editor .checks label'))if(label.textContent==='screenshot'){label.querySelector('input').checked=false;label.querySelector('input').dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v4');await identity('grant-reviewer');await selectReview();const revokeDiff=await evaluate(`document.getElementById('independent-review-diff').textContent`);assert.ok(revokeDiff.includes('screenshot'));await approve(5);assert.equal(await evaluate(`document.getElementById('agent-scope-screenshot').checked`),false);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks label')).filter(l=>l.textContent==='screenshot').every(l=>!l.querySelector('input').checked)`),true);
|
||||
|
||||
@@ -19,5 +19,6 @@ try {
|
||||
child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0);
|
||||
const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,5);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');assert.equal(p.agents.scopes.includes('screenshot'),false);assert.equal(p.agents.grants[0].scopes.includes('screenshot'),false);for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore','vault:prepare-submit']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));}
|
||||
assert.equal(p.passwordManager.controlledSubmission.enabled,true);assert.equal(p.passwordManager.controlledSubmission.forms[0].actionUrl,'https://login.company.example/signin');
|
||||
const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,controlledCredentialPolicyReviewed:true,prepareSubmitScopeDefaultOffAndReviewed:true,screenshotScopeDefaultOffAndReviewed:true,screenshotBaselineAndGrantRevocationReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
|
||||
const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,controlledCredentialPolicyReviewed:true,typedCredentialRulesReviewed:true,typedCredentialRulesAuditorReadonly:true,backgroundWorkerControlsDefaultOff:true,prepareSubmitScopeDefaultOffAndReviewed:true,screenshotScopeDefaultOffAndReviewed:true,screenshotBaselineAndGrantRevocationReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
} finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -14,7 +14,7 @@ process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry
|
||||
const temporary=mkdtempSync(join(tmpdir(),'safetynet-agent-visual-'));mkdirSync('artifacts',{recursive:true});
|
||||
if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-agent-visual-'))throw Error('Unsafe temporary fixture path');
|
||||
let blockPNG=false;const avFacts={scans:0,pngScans:0,pngBlocks:0},avSockets=new Set(),pngSignature=Buffer.from([137,80,78,71,13,10,26,10]);const av=net.createServer(socket=>{avSockets.add(socket);socket.on('close',()=>avSockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(input.length>4194304){socket.destroy();return;}if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options){input.fill(0);socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nISTag: "visual-fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}avFacts.scans++;const png=input.indexOf(pngSignature)>=0;if(png)avFacts.pngScans++;const blocked=png&&blockPNG;if(blocked)avFacts.pngBlocks++;input.fill(0);socket.end(blocked?'ICAP/1.0 204 No Content\r\nISTag: "visual-fixture"\r\nX-Infection-Found: Type=0; Threat=synthetic-visual;\r\n\r\n':'ICAP/1.0 204 No Content\r\nISTag: "visual-fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>av.once('listening',r));
|
||||
let requests=0;const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{requests++;if(req.url==='/av-stats'){res.setHeader('content-type','application/json');res.end(JSON.stringify(avFacts));return;}if(req.url==='/av-block')blockPNG=true;else if(['/public','/canvas','/sensitive','/closed-shadow','/open-shadow','/frames'].includes(req.url))blockPNG=false;res.setHeader('content-type','text/html');const base='<!doctype html><meta name="viewport" content="width=device-width"><style>body{margin:0;background:white;color:#123;font:28px Arial}h1{margin:70px}canvas{display:block}</style><title>Public visual fixture</title>';if(req.url==='/canvas')res.end(base+'<canvas width="1200" height="360"></canvas><script>const c=document.querySelector("canvas").getContext("2d");c.fillStyle="white";c.fillRect(0,0,1200,360);c.fillStyle="black";c.font="bold 48px Arial";c.fillText("CLASSIFIED ORCHID DOCUMENT",40,170);</script>');else if(req.url==='/sensitive')res.end(base+'<h1>Public form</h1><input type="password" value="synthetic-hidden-field" style="position:absolute;left:-9999px"><script>document.querySelectorAll=()=>[]</script>');else if(req.url==='/closed-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"closed"}).innerHTML='<input type="password" value="synthetic-closed-shadow-password">';document.querySelectorAll=()=>[]</script>`);else if(req.url==='/open-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"open"}).innerHTML='<p style="margin:70px;font:28px Arial">Public shadow content</p>'</script>`);else if(req.url==='/frames')res.end(base+'<h1>Public parent</h1><iframe src="/public"></iframe>');else res.end(base+'<h1>Public approved visual page</h1><p style="margin:70px">Research and documentation workspace.</p>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
|
||||
const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/av-stats'){res.setHeader('content-type','application/json');res.end(JSON.stringify(avFacts));return;}if(req.url==='/av-block')blockPNG=true;else if(['/public','/canvas','/sensitive','/closed-shadow','/open-shadow','/frames'].includes(req.url))blockPNG=false;res.setHeader('content-type','text/html');const base='<!doctype html><meta name="viewport" content="width=device-width"><style>body{margin:0;background:white;color:#123;font:28px Arial}h1{margin:70px}canvas{display:block}</style><title>Public visual fixture</title>';if(req.url==='/canvas')res.end(base+'<canvas width="1200" height="360"></canvas><script>const c=document.querySelector("canvas").getContext("2d");c.fillStyle="white";c.fillRect(0,0,1200,360);c.fillStyle="black";c.font="bold 48px Arial";c.fillText("CLASSIFIED ORCHID DOCUMENT",40,170);</script>');else if(req.url==='/sensitive')res.end(base+'<h1>Public form</h1><input type="password" value="synthetic-hidden-field" style="position:absolute;left:-9999px"><script>document.querySelectorAll=()=>[]</script>');else if(req.url==='/closed-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"closed"}).innerHTML='<input type="password" value="synthetic-closed-shadow-password">';document.querySelectorAll=()=>[]</script>`);else if(req.url==='/open-shadow')res.end(base+`<div id=host></div><script>document.getElementById("host").attachShadow({mode:"open"}).innerHTML='<p style="margin:70px;font:28px Arial">Public shadow content</p>'</script>`);else if(req.url==='/frames')res.end(base+'<h1>Public parent</h1><iframe src="/public"></iframe>');else res.end(base+'<h1>Public approved visual page</h1><p style="margin:70px">Research and documentation workspace.</p>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
|
||||
const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
|
||||
const p=store.get('policy');p.browser.homeUrl=origin+'/public';p.inspection.documentAnalysisEnabled=false;p.dlp.rules.push({id:'visual-confidential-fixture',label:'Synthetic visual confidential rule',kind:'literal',value:'CLASSIFIED ORCHID DOCUMENT',action:'block'});
|
||||
p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${av.address().port}/scan`,scanUploads:true,scanDownloads:false,scanClipboard:false,scanAI:false};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
// All wrappers observe the real native guards; they never replace enforcement.
|
||||
const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),https=require('node:https'),{X509Certificate}=require('node:crypto');
|
||||
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();
|
||||
const origin=process.env.SAFETYNET_SMOKE_ORIGIN,forbiddenOrigin=process.env.SAFETYNET_SMOKE_FORBIDDEN_ORIGIN,fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;
|
||||
const nativeSessions=new WeakMap(),records=[],requests=[],fromPartition=session.fromPartition.bind(session);
|
||||
session.fromPartition=(...args)=>{const s=fromPartition(...args);if(nativeSessions.has(s))return s;const record={index:records.length,partitionOwned:typeof args[0]==='string'&&args[0].startsWith('safetynet-agent-'),hooks:[],firstRequestHooks:null};records.push(record);nativeSessions.set(s,record);s.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=['127.0.0.1','127.0.0.2'].includes(req.hostname)&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});
|
||||
for(const name of ['onBeforeRequest','onBeforeSendHeaders','onBeforeRedirect','onCompleted','onErrorOccurred']){const original=s.webRequest[name].bind(s.webRequest);s.webRequest[name]=(...values)=>{const index=values.length-1,listener=values[index];if(typeof listener==='function'){record.hooks.push(name);values[index]=(...eventArgs)=>{const d=eventArgs[0];if(name==='onBeforeRequest'&&d.url?.startsWith(origin)){record.firstRequestHooks??=[...record.hooks];if(d.url.includes('/worker/'))requests.push({nativeSession:record.index,requestId:d.id,hasWebContents:Number.isInteger(d.webContentsId)&&d.webContentsId>0,hasFrame:!!d.frame,method:d.method,bytes:(d.uploadData||[]).reduce((n,v)=>n+(v.bytes?.length||0),0),sameInitiator:d.initiator===origin});}return listener(...eventArgs);};}return original(...values);};}
|
||||
for(const name of ['setPermissionRequestHandler','setPermissionCheckHandler','registerPreloadScript'])if(typeof s[name]==='function'){const original=s[name].bind(s);s[name]=(...values)=>{record.hooks.push(name);return original(...values);};}
|
||||
const proxy=s.setProxy.bind(s);s.setProxy=async(...values)=>{const result=await proxy(...values);record.hooks.push('proxyReady');return result;};return s;};
|
||||
const localWitnesses=[],inspection=require('../apps/browser/inspection.cjs'),startInspector=inspection.startInspector;inspection.startInspector=(...args)=>{const client=startInspector(...args),inspect=client.inspect.bind(client);client.inspect=async(bytes,policy)=>{const result=await inspect(bytes,policy);localWitnesses.push({decision:result.decision,ruleId:result.ruleId==='worker-yara-fixture'?'worker-yara-fixture':null,complete:result.coverage?.complete===true});return result;};return client;};
|
||||
require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'};
|
||||
async function until(fn){for(let i=0;i<250;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native worker fixture timeout');}
|
||||
const getFacts=()=>new Promise((resolve,reject)=>{const request=https.get(origin+'/facts',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(JSON.parse(body)));});request.on('error',reject);});
|
||||
app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;},overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const human=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(human);await until(()=>!human.isLoading()&&human.getURL()===origin+'/app');
|
||||
const connections={a:await ui('agentConnection','worker-a'),b:await ui('agentConnection','worker-b')};async function call(actor,action,args={}){const requestFile=path.join(app.getPath('userData'),'worker-request-'+actor+'.json');fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connections[actor].credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}
|
||||
const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});};
|
||||
stage='owned native tab creation';const owned={};for(const actor of ['a','b']){const response=await call(actor,'tabs:create',{url:origin+'/seed-'+actor});assert.equal(response.exit,0,'Owned native creation refused');assert.ok(response.result.sessionId);const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/seed-'+actor);owned[actor]={id:response.result.sessionId,page,session:page.session,record:nativeSessions.get(page.session)};assert.ok(owned[actor].record?.partitionOwned,'Actor uses immutable owned partition');assert.equal((await call(actor,'navigate',{sessionId:owned[actor].id,url:origin+'/app'})).exit,0);await until(()=>!page.isLoading()&&page.getURL()===origin+'/app');}
|
||||
assert.notEqual(owned.a.session,owned.b.session);assert.notEqual(owned.a.session,human.session);for(const actor of ['a','b']){const cookies=await owned[actor].session.cookies.get({url:origin,name:'worker-session'});assert.equal(cookies.length,1);assert.equal(cookies[0].value,actor.toUpperCase());const installed=owned[actor].record.firstRequestHooks;for(const hook of ['onBeforeRequest','onBeforeSendHeaders','onBeforeRedirect','onCompleted','onErrorOccurred','setPermissionRequestHandler','setPermissionCheckHandler','registerPreloadScript','proxyReady'])assert.ok(installed.includes(hook),'Missing guard before first native request: '+hook);}
|
||||
stage='cross actor control denied';assert.equal((await call('b','read',{sessionId:owned.a.id})).exit,1);assert.equal((await call('b','navigate',{sessionId:owned.a.id,url:origin+'/app'})).exit,1);
|
||||
const register=page=>page.executeJavaScript(`(async()=>{try{await navigator.serviceWorker.register('/sw.js',{scope:'/'});await Promise.race([navigator.serviceWorker.ready,new Promise((_,r)=>setTimeout(()=>r(Error('deadline')),5000))]);if(!navigator.serviceWorker.controller)await new Promise(r=>{const t=setTimeout(r,2000);navigator.serviceWorker.addEventListener('controllerchange',()=>{clearTimeout(t);r();},{once:true});});return !!navigator.serviceWorker.controller;}catch{return false;}})()`);
|
||||
const send=(page,url,body)=>page.executeJavaScript(`new Promise(resolve=>{const worker=navigator.serviceWorker.controller;if(!worker){resolve({ok:false});return;}const c=new MessageChannel(),t=setTimeout(()=>resolve({ok:false}),5000);c.port1.onmessage=e=>{clearTimeout(t);resolve({ok:e.data.ok===true});};worker.postMessage(${JSON.stringify({url,body})},[c.port2]);})`);
|
||||
stage='background gate defaults off';if(await register(owned.a.page))assert.equal((await send(owned.a.page,origin+'/worker/a','public-worker-message')).ok,false);assert.equal((await getFacts()).facts.publicA,0);
|
||||
stage='independent background policy enable';await change(p=>p.browser.allowAgentBackgroundWorkers=true);for(const actor of ['a','b']){await until(()=>!owned[actor].page.isLoading());assert.equal(await register(owned[actor].page),true,'Owned worker registration unavailable');}
|
||||
stage='both native workers public requests allowed';for(const actor of ['a','b'])assert.equal((await send(owned[actor].page,origin+'/worker/'+actor,'public-worker-message')).ok,true);const workerRequests=requests.filter(r=>!r.hasWebContents&&!r.hasFrame);assert.ok(workerRequests.some(r=>r.nativeSession===owned.a.record.index));assert.ok(workerRequests.some(r=>r.nativeSession===owned.b.record.index));
|
||||
stage='core exact body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','CORE_COMPANY_WORKER_LITERAL')).ok,false);stage='real local YARA body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','YARA_WORKER_ONLY_SECRET')).ok,false);assert.ok(localWitnesses.some(w=>w.decision==='block'&&w.ruleId==='worker-yara-fixture'));stage='real ICAP body blocks';assert.equal((await send(owned.a.page,origin+'/worker/a','ICAP_WORKER_ONLY_SECRET')).ok,false);stage='worker destination outside grant denied';assert.equal((await send(owned.a.page,forbiddenOrigin+'/forbidden','public-worker-message')).ok,false);
|
||||
stage='human worker remains ownerless denied';const humanRegistered=await register(human);if(humanRegistered)assert.equal((await send(human,forbiddenOrigin+'/human','public-worker-message')).ok,false);
|
||||
stage='scope revoke denies running owned worker';await change(p=>p.agents.grants.find(g=>g.id==='worker-a').scopes=p.agents.grants.find(g=>g.id==='worker-a').scopes.filter(s=>s!=='network:background'));if(!owned.a.page.isDestroyed())assert.equal((await send(owned.a.page,origin+'/worker/a','public-worker-message')).ok,false);let staleAllowed=false;try{const response=await owned.a.session.fetch(origin+'/worker/a',{method:'POST',body:'public-worker-message'});staleAllowed=response.ok;}catch{}assert.equal(staleAllowed,false);assert.equal((await send(owned.b.page,origin+'/worker/b','public-worker-message')).ok,true);const bCookie=await owned.b.session.cookies.get({url:origin,name:'worker-session'});assert.equal(bCookie.length,1);assert.equal(bCookie[0].value,'B');
|
||||
stage='independent receiver validates absence';const receiver=await getFacts();assert.equal(receiver.facts.publicA,1);assert.equal(receiver.facts.publicB,2);for(const name of ['core','yara','icap','forbidden'])assert.equal(receiver.facts[name],0);assert.ok(receiver.avFacts.workerScans>=3);assert.equal(receiver.avFacts.blocks,1);
|
||||
stage='actual worker observability';await wait(1500);const events=[];let after=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${after}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));for(const row of rows){assert.ok(!/CORE_COMPANY_WORKER_LITERAL|YARA_WORKER_ONLY_SECRET|ICAP_WORKER_ONLY_SECRET|worker-session=/.test(JSON.stringify(row)));events.push(row);}if(rows.length<1000)break;const next=Number(response.headers.get('x-safetynet-next-cursor'));assert.ok(next>after);after=next;}const workerEvents=events.filter(e=>e.safetynet?.agent?.action==='network:background'&&e.trace?.id);for(const principal of ['worker-a','worker-b'])assert.ok(workerEvents.some(e=>e.safetynet.agent.principal_id===principal&&e.safetynet.agent.credential_generation===1&&e.safetynet.agent.outcome==='authorized'),'Missing durable native worker owner');assert.ok(workerEvents.some(e=>e.safetynet.agent.principal_id==='worker-a'&&e.safetynet.agent.outcome==='denied'),'Revoked worker denial missing owner attribution');const traced=workerEvents.filter(e=>e.safetynet.agent.outcome==='authorized');let trace,selectedTraceId;for(const event of traced.reverse()){const candidate=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();if(candidate.spans?.some(s=>s.name==='browser.request')&&candidate.spans.some(s=>s.name==='browser.inspect.local')){trace=candidate;selectedTraceId=event.trace.id;break;}}assert.ok(trace,'Native worker trace absent');assert.ok(trace.spans.some(s=>s.name==='browser.worker'),'Worker ownership stage absent');const spanMap=new Map(trace.spans.map(s=>[s.spanId,s]));const workerSpan=trace.spans.find(s=>s.name==='browser.worker');let ancestor=workerSpan,parented=false;const seen=new Set();while(ancestor&&!seen.has(ancestor.spanId)){if(ancestor.name==='browser.request'){parented=true;break;}seen.add(ancestor.spanId);ancestor=spanMap.get(ancestor.parentSpanId);}assert.ok(parented,'Native worker authorization is not correlated beneath browser.request');assert.ok(!/CORE_COMPANY_WORKER_LITERAL|YARA_WORKER_ONLY_SECRET|ICAP_WORKER_ONLY_SECRET/.test(JSON.stringify(trace)));const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();
|
||||
fs.writeFileSync(path.resolve('artifacts/agent-worker-ownership-native-proof.json'),JSON.stringify({actualElectron:true,exactPinnedTLS:true,independentlyPublished:true,immutableNativePartitions:true,cookiesIsolated:true,crossActorAccessDenied:true,hooksInstalledBeforeFirstRequest:true,backgroundDefaultDenied:true,workerNativeAttribution:workerRequests,humanRegistered,humanBackgroundDenied:true,coreBodyBlocked:true,actualYARABlocked:true,actualICAPBlocked:true,forbiddenHostDenied:true,existingWorkerScopeRevocationEnforced:true,staleNativeSessionDenied:true,otherActorCookiePreserved:true,receiver,records,localWitnesses,traceId:selectedTraceId,workerAuditFacts:workerEvents.map(e=>({action:e.safetynet.agent.action,outcome:e.safetynet.agent.outcome,principal:e.safetynet.agent.principal_id,generation:e.safetynet.agent.credential_generation,traceId:e.trace.id})),stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Native agent worker ownership proof passed');for(const page of pages)if(!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){fs.writeFileSync(path.resolve('artifacts/agent-worker-ownership-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,records,requests,localWitnesses},null,2));console.error('Native worker proof failed at '+stage+': '+error.name);for(const page of pages)if(!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Native worker proof: real policy publication, DLP, AV and independent receivers.
|
||||
import {createServer} from 'node:https';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve,sep} from 'node:path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable');
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry();
|
||||
const temporary=mkdtempSync(join(tmpdir(),'safetynet-worker-owner-'));mkdirSync('artifacts',{recursive:true});
|
||||
if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-worker-owner-'))throw Error('Unsafe temporary fixture path');
|
||||
const markers={core:'CORE_COMPANY_WORKER_LITERAL',yara:'YARA_WORKER_ONLY_SECRET',icap:'ICAP_WORKER_ONLY_SECRET'},facts={publicA:0,publicB:0,core:0,yara:0,icap:0,forbidden:0},avFacts={scans:0,workerScans:0,blocks:0},avSockets=new Set();
|
||||
const av=net.createServer(socket=>{avSockets.add(socket);socket.on('close',()=>avSockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(input.length>4194304){input.fill(0);socket.destroy();return;}if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options){input.fill(0);socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nISTag: "worker-fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}avFacts.scans++;if(input.includes(Buffer.from('public-worker-message')))avFacts.workerScans++;const blocked=input.includes(Buffer.from(markers.icap));if(blocked)avFacts.blocks++;input.fill(0);socket.end(blocked?'ICAP/1.0 204 No Content\r\nISTag: "worker-fixture"\r\nX-Infection-Found: Type=0; Threat=synthetic-worker;\r\n\r\n':'ICAP/1.0 204 No Content\r\nISTag: "worker-fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>av.once('listening',r));
|
||||
const tls={cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')};
|
||||
const forbidden=createServer(tls,(req,res)=>{facts.forbidden++;req.resume();res.end('public');}).listen(0,'127.0.0.2');await new Promise(r=>forbidden.once('listening',r));
|
||||
const forbiddenOrigin=`https://127.0.0.2:${forbidden.address().port}`;
|
||||
const worker=`self.addEventListener('install',e=>e.waitUntil(self.skipWaiting()));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('message',e=>{const p=e.ports[0];e.waitUntil((async()=>{try{const r=await fetch(e.data.url,{method:'POST',headers:{'content-type':'text/plain'},body:e.data.body,credentials:'include'});p.postMessage({ok:r.ok});}catch{p.postMessage({ok:false});}})());});`;
|
||||
const sink=createServer(tls,(req,res)=>{if(req.url==='/facts'){res.setHeader('content-type','application/json');res.end(JSON.stringify({facts,avFacts}));return;}if(req.url==='/sw.js'){res.setHeader('content-type','application/javascript');res.setHeader('service-worker-allowed','/');res.end(worker);return;}if(req.method==='POST'){let chunks=[],size=0;req.on('data',chunk=>{size+=chunk.length;if(size>65536){req.destroy();return;}chunks.push(chunk);});req.on('end',()=>{const body=Buffer.concat(chunks);for(const [kind,marker]of Object.entries(markers))if(body.includes(Buffer.from(marker)))facts[kind]++;if(body.equals(Buffer.from('public-worker-message'))){if(req.url==='/worker/a')facts.publicA++;else if(req.url==='/worker/b')facts.publicB++;}for(const chunk of chunks)chunk.fill(0);body.fill(0);chunks=[];res.end('accepted');});return;}if(req.url==='/seed-a')res.setHeader('set-cookie','worker-session=A; Secure; HttpOnly; SameSite=Strict; Path=/');if(req.url==='/seed-b')res.setHeader('set-cookie','worker-session=B; Secure; HttpOnly; SameSite=Strict; Path=/');res.setHeader('content-type','text/html');res.end('<!doctype html><title>Public worker fixture</title><h1>Public worker ownership fixture</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
|
||||
const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
|
||||
const p=store.get('policy');p.browser.homeUrl=origin+'/app';p.browser.allowAgentBackgroundWorkers=false;p.dlp.rules.push({id:'worker-core-fixture',label:'Synthetic confidential worker content',kind:'literal',value:markers.core,action:'block'});p.security.yara.enabled=true;p.security.yara.rules=[{id:'worker-yara-fixture',name:'Synthetic worker YARA',source:`rule worker_yara_fixture { strings: $a = "${markers.yara}" condition: $a }`}];p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${av.address().port}/scan`,scanUploads:true,scanDownloads:false,scanClipboard:false,scanAI:false};
|
||||
const scopes=['sessions','navigate','read','tabs:create','network:background'];p.agents={...p.agents,enabled:true,identityMode:'individual',scopes,allowedHosts:['127.0.0.1'],grants:['worker-a','worker-b'].map(id=>({id,name:id,enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes,allowedHosts:['127.0.0.1'],generation:1}))};p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'worker-fixture');
|
||||
const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-worker-reviewer',role:'admin'}));
|
||||
const child=spawn(createRequire(import.meta.url)('electron'),['scripts/agent-worker-ownership-smoke.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ORIGIN:origin,SAFETYNET_SMOKE_FORBIDDEN_ORIGIN:forbiddenOrigin}});child.stdout.on('data',v=>process.stdout.write(v));child.stderr.on('data',v=>process.stderr.write(v));const deadline=setTimeout(()=>child.kill(),240000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline);
|
||||
for(const socket of avSockets)socket.destroy();await new Promise(r=>av.close(r));for(const server of [management,sink,forbidden])server.closeAllConnections();await Promise.all([management,sink,forbidden].map(server=>new Promise(r=>server.close(r))));store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});process.exit(exit);
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -22,5 +21,6 @@ try{
|
||||
const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.deepEqual(policy.browser.bookmarks.managed.map(({title,url,folder})=>({title,url,folder})),[{title:'Company intranet',url:'https://intranet.synthetic.example/',folder:'Company'}]);assert.equal(policy.browser.bookmarks.adminReadUserBookmarks,false);assert.equal(store.personalBookmarks('synthetic-bookmark-device').revision,1);assert.equal(store.personalBookmarks('synthetic-bookmark-device').items[0].title,'Remote personal reference');
|
||||
const report={actualAdministratorUi:true,managedTableRoundtrip:true,explicitPublication:true,deviceReadEdit:true,revisionCompared:true,deviceEditGlobalPolicyUnchanged:true,privacyRevoked:true,nodeUnavailable:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/bookmarks-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-bookmarks-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardware
|
||||
app.whenReady().then(()=>{const requests=session.fromPartition('persist:safetynet-managed').webRequest,original=requests.onBeforeSendHeaders.bind(requests);requests.onBeforeSendHeaders=(filter,listener)=>original(filter,(details,callback)=>{if(details.url.endsWith('/public')){const entries=Object.entries(details.requestHeaders),one=entries.find(([name])=>name.toLowerCase()==='x-credential-one'),two=entries.find(([name])=>name.toLowerCase()==='x-credential-two'),known='fixtureControlledCredentialDelta0123456789';if(one&&two){headerFacts.push({names:entries.map(([name])=>name),fragmentOneIndex:entries.indexOf(one),fragmentTwoIndex:entries.indexOf(two),fullJoinMatch:entries.map(([,value])=>value).join('').includes(known),pairJoinMatch:(one[1]+two[1])===known,reversePairJoinMatch:(two[1]+one[1])===known});fs.writeFileSync(path.resolve('artifacts/controlled-login-header-facts.json'),JSON.stringify(headerFacts,null,2));}}if(fixtureCredentialHeader&&details.resourceType==='mainFrame'&&details.method==='POST')details.requestHeaders['X-Synthetic-Credential']='sk-syntheticControlledLoginCredential0123456789';listener(details,callback);});});require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'};
|
||||
async function until(fn){for(let i=0;i<200;i++){const r=await fn();if(r)return r;await wait(100);}throw Error('Controlled login fixture timeout');}
|
||||
app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);if(!result.ok)throw Object.assign(Error('Native UI operation failed'),{operation:method,reason:/not managed|not ready|paused/i.test(result.error||'')?'not-ready':/context.*changed/i.test(result.error||'')?'context-changed':/BLOCKED_BY_CLIENT/i.test(result.error||'')?'native-request-blocked':/ERR_ABORTED/i.test(result.error||'')?'native-navigation-aborted':'ui-operation-refused'});return result.data;};const overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login');const a=await ui('agentConnection','vault-a'),b=await ui('agentConnection','vault-b'),requestFile=path.join(app.getPath('userData'),'controlled-request.json');async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}const sessionId=(await call(a,'sessions')).result[0].id,master='Synthetic master password for controlled native proof',password='sk-syntheticControlledLoginCredential0123456789',username='public-native-user',alternatePassword='fixtureControlledCredentialDelta0123456789';stage='human unlock';await ui('vaultCreate',master);const saved=await ui('vaultSave',{origin,username,password,label:'Approved synthetic login'});const reset=async(mode='200')=>{await ui('navigate',origin+'/login/'+mode);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login/'+mode);await ui('vaultUnlock',master);win.focus();page.focus();await wait(100);};const stats=()=>new Promise((resolve,reject)=>{const req=require('node:https').get(origin+'/stats',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},res=>{let body='';res.on('data',v=>body+=v);res.on('end',()=>resolve(JSON.parse(body)));});req.on('error',reject);});const inventory=async()=>{const r=await call(a,'vault:list',{sessionId});assert.equal(r.exit,0);return r.result[0].credentialRef;};const submit=async()=>{const terminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native form navigation terminal timeout'));},22000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript("document.querySelector('form').requestSubmit()");await terminal;await wait(200);};const prepare=async()=>{const ref=await inventory(),r=await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref});assert.equal(r.exit,0,'Agent prepare failed');assert.deepEqual(r.result,{completed:true,prepared:true});return ref;};const count=async()=>{const result=await fetch(base+'/api/admin/overview',{headers});assert.equal(result.status,200);return (await result.json()).events.filter(e=>e.kind==='vault.submission-authorized').length;};
|
||||
app.whenReady().then(async()=>{const pages=[];let stage='startup';try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);if(!result.ok)throw Object.assign(Error('Native UI operation failed'),{operation:method,reason:/not managed|not ready|paused/i.test(result.error||'')?'not-ready':/context.*changed/i.test(result.error||'')?'context-changed':/BLOCKED_BY_CLIENT/i.test(result.error||'')?'native-request-blocked':/ERR_ABORTED/i.test(result.error||'')?'native-navigation-aborted':'ui-operation-refused'});return result.data;};const overview=async()=>(await fetch(base+'/api/admin/overview',{headers})).json();stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login');const a=await ui('agentConnection','vault-a'),b=await ui('agentConnection','vault-b'),requestFile=path.join(app.getPath('userData'),'controlled-request.json');async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}const sessionId=(await call(a,'sessions')).result[0].id,master='Synthetic master password for controlled native proof',password='sk-syntheticControlledLoginCredential0123456789',username='public-native-user',alternatePassword='fixtureControlledCredentialDelta0123456789';stage='human unlock';await ui('vaultCreate',master);const saved=await ui('vaultSave',{origin,username,password,label:'Approved synthetic login'});const reset=async(mode='200')=>{await ui('navigate',origin+'/login/'+mode);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login/'+mode);await ui('vaultUnlock',master);win.focus();page.focus();await wait(100);};const stats=()=>new Promise((resolve,reject)=>{const req=require('node:https').get(origin+'/stats',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},res=>{let body='';res.on('data',v=>body+=v);res.on('end',()=>resolve(JSON.parse(body)));});req.on('error',reject);});const inventory=async()=>{const r=await call(a,'vault:list',{sessionId});assert.equal(r.exit,0);return r.result[0].credentialRef;};const submit=async()=>{const terminal=new Promise((resolve,reject)=>{const timer=setTimeout(()=>{page.removeListener('did-stop-loading',done);reject(Error('Native form navigation terminal timeout'));},22000);const done=()=>{clearTimeout(timer);resolve();};page.once('did-stop-loading',done);});await page.executeJavaScript("document.querySelector('form').requestSubmit()");await terminal;await wait(200);};const prepare=async()=>{const ref=await inventory(),r=await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref});assert.equal(r.exit,0,'Agent prepare failed');assert.deepEqual(r.result,{completed:true,prepared:true});return ref;};
|
||||
stage='ordinary fill remains DLP blocked';await reset();assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:await inventory()})).exit,0);await submit();assert.equal((await stats()).submitted,0);
|
||||
stage='agent prepare does not submit';await reset();let ref=await prepare();assert.equal((await stats()).submitted,0);assert.equal((await call(a,'vault:prepare-submit',{sessionId,credentialRef:ref})).exit,1);assert.equal((await call(b,'vault:prepare-submit',{sessionId,credentialRef:await inventory()})).exit,1);await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/signin');assert.equal((await stats()).submitted,1);
|
||||
stage='controlled302 safeGET';await reset('302');await prepare();await submit();await until(()=>!page.isLoading()&&page.getURL()===origin+'/final');assert.equal((await stats()).submitted,2);assert.equal((await stats()).finalGets,1);
|
||||
|
||||
@@ -5,4 +5,5 @@ try{
|
||||
store=openStore(join(directory,'management'));const policy=store.get('policy');policy.browser.homeUrl=sinkUrl;policy.browser.allowedHosts=['127.0.0.1'];store.put('policy',policy);const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'synthetic-developer');const config={adminToken,publicUrl:'http://127.0.0.1',secure:false};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`;
|
||||
const env={...process.env,SAFETYNET_DEVTOOLS_SERVER:config.publicUrl,SAFETYNET_DEVTOOLS_CODE:code,SAFETYNET_DEVTOOLS_ADMIN:adminToken,SAFETYNET_DEVTOOLS_SINK:sinkUrl,SAFETYNET_DEVTOOLS_USERDATA:join(directory,'browser'),SAFETYNET_SMOKE_USERDATA:join(directory,'browser')};delete env.ELECTRON_RUN_AS_NODE;
|
||||
child=spawn(require('electron'),['scripts/devtools-profile-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',b=>process.stdout.write(b));child.stderr.on('data',b=>process.stderr.write(String(b).replaceAll(code,'[fixture enrollment]').replaceAll(adminToken,'[fixture admin]')));const timer=setTimeout(()=>child.kill(),90000);const exit=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Native developer-profile proof failed');
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
}finally{child?.kill();for(const service of [server,sink])if(service)await new Promise(r=>{service.closeAllConnections();service.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-devtools-proof-'))throw Error('Unsafe fixture cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -24,5 +24,6 @@ try{
|
||||
assert.equal(policy.diagnostics.enabled,true);assert.equal(policy.diagnostics.mode,'debug');assert.equal(policy.diagnostics.endpoint,'https://collector.synthetic.example');assert.ok(policy.agents.scopes.includes('bookmarks:mutate'));
|
||||
const report={signedDiagnosticsUi:true,newAgentScopesUi:true,adminUi:true,isolatedControlPlane:true,previewDidNotPublish:true,explicitSignedPublication:true,integrationFormRoundtrip:true,optionsOnlyProbe:true,probeContentSent:false,cspPreserved:true,nodeUnavailable:true,hiddenCpuWindow:true,policyVersion:policy.version,groupedNavigation:true,draftChangeReview:true,reviewDoesNotPublish:true,fleetAndAuditFilters:true,consoleThemePersistence:true,mobileNoHorizontalOverflow:true,readOnlyRoleControls:true,sessionInventoryUi:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/enterprise-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();for(const service of [server,icap])if(service)await new Promise(r=>{service.closeAllConnections?.();service.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-enterprise-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto');
|
||||
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();const mode=process.env.SAFETYNET_WT_MODE,target=process.env.SAFETYNET_WT_TARGET,hashBytes=Array.from(Buffer.from(process.env.SAFETYNET_WT_HASH,'hex')),origin=process.env.SAFETYNET_SMOKE_ORIGIN;let actualDiagnostics,nativeWTCallbacks=0;
|
||||
if(mode==='managed'){const partition=session.fromPartition.bind(session),seenSessions=new WeakSet();session.fromPartition=(...args)=>{const current=partition(...args);if(!seenSessions.has(current)){seenSessions.add(current);const register=current.webRequest.onBeforeRequest.bind(current.webRequest);current.webRequest.onBeforeRequest=(...values)=>{const index=values.length-1,listener=values[index];if(typeof listener==='function')values[index]=(details,callback)=>{if(details.url?.startsWith(target))nativeWTCallbacks++;return listener(details,callback);};return register(...values);};}return current;};const diagnosticModule=require('../apps/browser/diagnostics.cjs'),createDiagnostics=diagnosticModule.createDiagnostics;diagnosticModule.createDiagnostics=(...args)=>{const value=createDiagnostics(...args);actualDiagnostics=value;return value;};require('../apps/browser/main.cjs');}else app.enableSandbox();
|
||||
// Executed in the real Chromium Window and dedicated Worker, without a shim.
|
||||
/* global WebTransport */
|
||||
async function attemptWT(url,hash){const result={api:typeof WebTransport==='function',connected:false,datagramWritten:false,streamWritten:false,errorClass:null};let transport;try{transport=new WebTransport(url,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('timeout')),5000))]);result.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'),datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();result.datagramWritten=true;const stream=await transport.createUnidirectionalStream(),writer=stream.getWriter();await writer.write(bytes);await writer.close();result.streamWritten=true;await new Promise(r=>setTimeout(r,350));}catch(error){result.errorClass=['Error','TypeError','WebTransportError','NetworkError'].includes(error.name)?error.name:'OtherError';}finally{transport?.close();}return result;}
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Full main WebTransport fixture timeout');}
|
||||
async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};onmessage=async e=>{postMessage(await attempt(e.data.url,e.data.hash));close();};`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};worker.postMessage({url:${JSON.stringify(target+'/worker')},hash:${JSON.stringify(hashBytes)}});})`);return {window,worker};}
|
||||
app.whenReady().then(async()=>{let page,win,stage='startup';try{const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;if(mode==='baseline'){session.defaultSession.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});let callbacks=0;session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{callbacks++;callback({cancel:true});});win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});page=win.webContents;await win.loadURL(origin+'/workspace');const realms=await attemptRealms(page);assert.equal(callbacks,0);for(const realm of [realms.window,realms.worker])assert.ok(realm.connected&&realm.datagramWritten&&realm.streamWritten);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualElectron:true,mode,realms,webRequestCallbacks:callbacks,electron:process.versions.electron,chromium:process.versions.chrome},null,2));win.destroy();app.exit(0);return;}
|
||||
win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay');
|
||||
const change=async proxy=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.proxy=proxy;const proposal=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposal.status,202);const review=await proposal.json();const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');};stage='reviewed direct upstream policy';await change({mode:'direct'});await test('reviewed DIRECT upstream retains relay');stage='reviewed PAC direct upstream policy';await change({mode:'pac_script',pacUrl:origin+'/direct.pac'});await test('reviewed PAC DIRECT upstream retains relay');
|
||||
stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),unsupportedTransportAuditGap:'Chromium WebTransport bypasses Electron webRequest; receiver absence is native containment evidence, not an attributed per-attempt DLP audit event.',diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Full enrolled main WebTransport protection passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){fs.writeFileSync(path.resolve('artifacts/fullmain-wt-native-error.json'),JSON.stringify({mode,stage,name:error.name},null,2));console.error('Full main WebTransport failed at '+stage+': '+error.name);if(mode==='managed'&&page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,21 @@
|
||||
// Real Window/Worker QUIC control, then enrolled production main across policies.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer} from 'node:https';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {randomBytes,createHash} from 'node:crypto';
|
||||
import {mkdtempSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve,sep} from 'node:path';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
const collector='http://127.0.0.1:4318';assert.equal((await fetch(collector+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(),root=mkdtempSync(join(tmpdir(),'safetynet-fullmain-wt-'));mkdirSync('artifacts',{recursive:true});assert.ok(resolve(root).startsWith(resolve(tmpdir())+sep)&&root.split(/[\\/]/).at(-1).startsWith('safetynet-fullmain-wt-'));
|
||||
const files=['apps/browser/main.cjs','apps/browser/startup-security.cjs','apps/browser/local-dlp-relay.cjs','apps/browser/diagnostics.cjs','packages/core/policy.mjs','package.json'],sourceHashFacts=Object.fromEntries(files.map(file=>[file,createHash('sha256').update(readFileSync(file)).digest('hex')]));let python,page,management,store,child,stage='receiver startup';const ordinary={posts:0,pacRequests:0};
|
||||
try{python=spawn('python',['scripts/webtransport-receiver.py',root],{env:{...process.env,PYTHONPATH:resolve('artifacts/webtransport-python')},windowsHide:true,stdio:['ignore','pipe','pipe']});python.stderr.on('data',()=>{});const metadata=await new Promise((accept,reject)=>{let text='';const timer=setTimeout(()=>reject(Error('Pinned QUIC receiver timeout')),15000);python.stdout.on('data',chunk=>{text+=chunk;try{const value=JSON.parse(text.split('\n')[0]);clearTimeout(timer);accept(value);}catch{}});python.once('error',()=>reject(Error('Pinned QUIC receiver startup refused')));python.once('exit',()=>{clearTimeout(timer);reject(Error('Pinned QUIC receiver exited'));});});const stats=`http://127.0.0.1:${metadata.statsPort}/facts`,target=`https://127.0.0.1:${metadata.quicPort}/webtransport`;
|
||||
page=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/direct.pac'){ordinary.pacRequests++;res.setHeader('content-type','application/x-ns-proxy-autoconfig');res.end('function FindProxyForURL(url,host) { return "DIRECT"; }');return;}if(req.method==='POST'){ordinary.posts++;req.resume();res.end('accepted');return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Native transport fixture</title><h1>Public native transport workspace</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>page.once('listening',r));const origin=`https://127.0.0.1:${page.address().port}`,env={...process.env,SAFETYNET_WT_TARGET:target,SAFETYNET_WT_HASH:metadata.hash,SAFETYNET_WT_STATS:stats,SAFETYNET_SMOKE_ORIGIN:origin};delete env.ELECTRON_RUN_AS_NODE;
|
||||
const run=async(mode,extra={})=>{const output=join(root,mode+'.json');child=spawn(createRequire(import.meta.url)('electron'),['scripts/fullmain-wt-native.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...env,SAFETYNET_WT_MODE:mode,SAFETYNET_SMOKE_USERDATA:join(root,mode+'-profile'),SAFETYNET_WT_OUTPUT:output,...extra}});child.stdout.on('data',chunk=>process.stdout.write(chunk));child.stderr.on('data',chunk=>process.stderr.write(chunk));const timer=setTimeout(()=>child.kill(),120000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Full main native transport phase failed');return JSON.parse(readFileSync(output,'utf8'));};
|
||||
stage='real baseline delivery';const before=await(await fetch(stats)).json(),baseline=await run('baseline'),after=await(await fetch(stats)).json();assert.ok(after.bytes-before.bytes>=132);assert.ok(after.workerBytes-before.workerBytes>=66);assert.ok(after.datagrams>before.datagrams&&after.streams>before.streams&&after.marker);const baselineReceiverDelta=Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]]));
|
||||
store=openStore(join(root,'db'));const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'),reviewer=randomBytes(32).toString('base64url'),policy=store.get('policy');policy.browser.homeUrl=origin+'/workspace';policy.browser.allowedHosts=['127.0.0.1'];policy.agents.enabled=false;policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',policy);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'fullmain-wt-fixture');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-wt-reviewer',role:'admin'}));const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
|
||||
stage='real enrolled main and reviewed policies';const managed=await run('managed',{SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer});assert.equal(managed.phases.length,3);assert.equal(ordinary.posts,3);assert.ok(ordinary.pacRequests>=1,'PAC was not actually retrieved');for(const file of files)assert.equal(createHash('sha256').update(readFileSync(file)).digest('hex'),sourceHashFacts[file],'Production source changed during native proof');const report={actualFullMain:true,actualPinnedHTTP3Receiver:true,aioquicVersion:metadata.aioquic,sourceHashFacts,baseline,baselineReceiverDelta,managed,ordinary,unsupportedTransportAuditGap:managed.unsupportedTransportAuditGap};writeFileSync('artifacts/fullmain-webtransport-native-proof.json',JSON.stringify(report,null,2));console.log('Full main pinned Window/Worker WebTransport proof passed');
|
||||
}catch(error){writeFileSync('artifacts/fullmain-wt-harness-error.json',JSON.stringify({stage,name:error.name},null,2));console.error('Full main WT harness failed at '+stage+': '+error.name);process.exitCode=1;}finally{child?.kill();python?.kill();for(const server of [page,management])if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}store?.db.close();await telemetry?.shutdown();rmSync(root,{recursive:true,force:true});}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -24,6 +23,7 @@ try{
|
||||
const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,config.requireIndependentPolicyApproval?2:3);const group=store.getRaw('policyGroups').find(group=>group.id==='engineering');assert.equal(group.rollout.stage,config.requireIndependentPolicyApproval?'pilot':'all');assert.deepEqual([...group.memberDeviceIds].sort(),['member-device','pilot-device']);assert.equal(group.restrictions.disableAI,true);
|
||||
const report={actualAdministratorUi:true,explicitGroupMembership:true,pilotCohortPublished:true,noNode:true,cspPreserved:true,...(config.requireIndependentPolicyApproval?{operatorProposalLeftPolicyUnchanged:true,exactMembersCohortRestrictionsReviewed:true,distinctAdministratorApproval:true}:{effectivePreview:true,explicitExpansion:true,reportedVersionDisplayed:true})};mkdirSync('artifacts',{recursive:true});writeFileSync(config.requireIndependentPolicyApproval?'artifacts/groups-review-admin-proof.json':'artifacts/groups-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-groups-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -10,7 +9,6 @@ import { randomBytes } from 'node:crypto';
|
||||
import { digest } from '../apps/server/store.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { createApp } from '../apps/server/app.mjs';
|
||||
import { verifyPolicy } from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-incident-admin-smoke-'));
|
||||
let store,server,child;
|
||||
try{
|
||||
@@ -24,6 +22,7 @@ try{
|
||||
const row=store.db.prepare('SELECT * FROM incident_cases').get();assert.equal(row.revision,3);assert.equal(row.status,'resolved');assert.equal(row.assignee,'Synthetic SOC');
|
||||
const report={actualAdministratorUi:true,createFromSecurityEvent:true,statusSeverityAssignment:true,revisionConflictPreservesEdits:true,historyShown:true,adversarialNotesRenderedAsText:true,filters:true,auditorReadOnly:true,rawContentExcluded:true,noNode:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/incident-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-incident-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -24,5 +23,6 @@ try{
|
||||
const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.equal(store.db.prepare('SELECT document_analysis_enabled FROM devices WHERE id=?').get('synthetic-bookmark-device').document_analysis_enabled,1);assert.equal(policy.brand.name,'Independently approved');
|
||||
const report={actualAdministratorUi:true,mandatoryIndependentApproval:true,payloadDiff:true,selfApprovalHttpDenied:true,distinctReviewer:true,operatorPrepareOnly:true,inspectionOperationVisible:true,inspectionUnchangedUntilApproval:true,rejectedAndStaleVisible:true,publishedOnlyAfterApproval:true,nodeUnavailable:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/independent-review-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-independent-review-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const {app,BrowserWindow,webContents}=require('electron');const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path');
|
||||
const {app,BrowserWindow,webContents}=require('electron');const assert=require('node:assert/strict'),fs=require('node:fs');
|
||||
app.setPath('userData',process.env.SAFETYNET_TABS_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');
|
||||
function finish(code){for(const contents of webContents.getAllWebContents()){if(contents.getURL().endsWith('index.html'))continue;require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(contents);}app.exit(code);}
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn,label){for(let i=0;i<150;i++){if(await fn())return;await wait(100);}throw Error(label+' timed out');}
|
||||
|
||||
@@ -29,4 +29,5 @@ try{
|
||||
child=spawn(require('electron'),['scripts/native-tabs-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',b=>process.stdout.write(b));child.stderr.on('data',b=>{const message=String(b).replaceAll(code,'[fixture enrollment]').replaceAll(adminToken,'[fixture admin]');process.stderr.write(message);});
|
||||
const timer=setTimeout(()=>child.kill(),120000);const codeExit=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(codeExit,0,'Actual native tabs proof failed');
|
||||
assert.equal(posts.length,0,'Blocked fixture data reached receiver');await sdk.shutdown();sdk=null;const diagnostics=diagnosticStatus();assert.ok(diagnostics.spans>0&&diagnostics.logs>0&&diagnostics.timings&&diagnostics.parentChild&&diagnostics.correlatedLogs);assert.equal(diagnostics.sensitivePresent,false);assert.equal(diagnostics.crossProcessTrace,true,'Browser management span must parent actual backend HTTP request');for(const name of ['browser.ipc','browser.navigation','browser.dns','browser.request'])assert.ok(diagnostics.names.includes(name),`Missing native timing ${name}`);writeFileSync('artifacts/native-browser-diagnostics-proof.json',JSON.stringify(diagnostics,null,2));mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/native-tabs-receiver-proof.json',JSON.stringify({noBlockedPosts:true,requests,posts},null,2));
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
}finally{child?.kill();await sdk?.shutdown();if(previousEndpoint===undefined)delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;else process.env.OTEL_EXPORTER_OTLP_ENDPOINT=previousEndpoint;for(const server of [management,sink,collector])if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-native-tabs-'))throw Error('Unsafe fixture cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
export const hardenedElectronFuses=Object.freeze({runAsNode:false,enableCookieEncryption:true,enableNodeOptionsEnvironmentVariable:false,enableNodeCliInspectArguments:false,enableEmbeddedAsarIntegrityValidation:true,onlyLoadAppFromAsar:true});
|
||||
export function assertEmbeddedIntegrity(entries,header){
|
||||
const matching=entries.filter(row=>row.file==='resources\\app.asar');assert.equal(matching.length,1,'Missing or ambiguous embedded ASAR integrity');const entry=matching[0];assert.equal(entry.alg,'SHA256');assert.equal(entry.value,createHash('sha256').update(header).digest('hex'),'Embedded ASAR integrity does not match archive header');
|
||||
}
|
||||
export async function verifyPackagedFuses(executable,archive){
|
||||
const {getCurrentFuseWire,FuseV1Options}=require('@electron/fuses');const wire=await getCurrentFuseWire(executable);
|
||||
const expected={RunAsNode:false,EnableCookieEncryption:true,EnableNodeOptionsEnvironmentVariable:false,EnableNodeCliInspectArguments:false,EnableEmbeddedAsarIntegrityValidation:true,OnlyLoadAppFromAsar:true};
|
||||
for(const [name,enabled] of Object.entries(expected))assert.equal(wire[FuseV1Options[name]],enabled?49:48,`Packaged security fuse mismatch: ${name}`);
|
||||
const {NtExecutable,NtExecutableResource}=require('resedit'),binary=NtExecutable.from(await readFile(executable)),resources=NtExecutableResource.from(binary);const entries=resources.entries.filter(row=>row.type==='INTEGRITY'&&row.id==='ELECTRONASAR');assert.equal(entries.length,1,'Missing embedded Windows ASAR integrity resource');
|
||||
const integrity=JSON.parse(Buffer.from(entries[0].bin).toString('utf8'));const {getRawHeader}=require('@electron/asar');assertEmbeddedIntegrity(integrity,getRawHeader(archive).headerString);return expected;
|
||||
}
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import {hardenedElectronFuses,verifyPackagedFuses} from './package-fuses.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {readFileSync,readdirSync} from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
@@ -16,8 +17,10 @@ const electronDirectory=path.dirname(createRequire(import.meta.url)('electron'))
|
||||
if(readFileSync(path.join(electronDirectory,'version'),'utf8').trim().replace(/^v/,'')!==configuration.devDependencies.electron)throw Error('Installed Electron does not match the build pin');
|
||||
function sourceSnapshot(){const entries=[];function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}}for(const directory of ['apps/browser','packages/core'])walk(directory);entries.push(['package.json',createHash('sha256').update(readFileSync('package.json')).digest('hex')]);return JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])));}
|
||||
const sourcesBefore=sourceSnapshot();
|
||||
const r=spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',`--${platform}`,'--dir',`--config.electronDist=${electronDirectory}`,`--config.productName=${name}`,`--config.appId=${id}`],{stdio:'inherit',env:process.env});
|
||||
const r=spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',`--${platform}`,'--dir',`--config.electronDist=${electronDirectory}`,`--config.productName=${name}`,`--config.appId=${id}`,...Object.entries(hardenedElectronFuses).map(([key,value])=>`--config.electronFuses.${key}=${value}`)],{stdio:'inherit',env:process.env});
|
||||
if(r.status!==0)process.exit(r.status??1);
|
||||
if(sourceSnapshot()!==sourcesBefore)throw Error('Browser sources changed during packaging; discard this artifact and rebuild from stable sources');
|
||||
const resources=path.join(output,'win-unpacked/resources');
|
||||
await verifyPackagedFuses(path.join(output,'win-unpacked',`${name}.exe`),path.join(resources,'app.asar'));
|
||||
console.log('Packaged Electron security fuses and embedded ASAR integrity verified.');
|
||||
const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});process.exit(acl.status??1);
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -22,5 +21,6 @@ try{
|
||||
const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,3);assert.equal(policy.brand.name,'SafetyNet');assert.equal(policy.agents.enabled,false);assert.deepEqual(policy.security.dns.blockedHosts,[]);assert.deepEqual(policy.security.content.blockedPhrases,[]);
|
||||
const report={actualAdministratorUi:true,persistedHistory:true,serverDiff:true,explicitRestoreApproval:true,monotonicRestore:true,draftPreservedDuringReview:true,structuredDnsContentAgentControls:true,nodeUnavailable:true,cspPreserved:true};mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/policy-history-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-policy-history-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Run with Node --cpu-prof / --heap-prof; profiles belong in ignored artifacts.
|
||||
import {createRequire} from 'node:module';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
const require=createRequire(import.meta.url);
|
||||
for(const name of ['diagnostics.cjs','inspect-text-representations.cjs']){
|
||||
const before=process.memoryUsage(),started=performance.now();
|
||||
require('../apps/browser/'+name);
|
||||
const after=process.memoryUsage();
|
||||
console.log(JSON.stringify({module:name,loadMs:Number((performance.now()-started).toFixed(3)),heapDeltaBytes:after.heapUsed-before.heapUsed,rssDeltaBytes:after.rss-before.rss}));
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Finite, isolated administrator workflow proof using actual UI and HTTP routes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep, basename } from 'node:path';
|
||||
@@ -24,6 +23,7 @@ try{
|
||||
const policy=verifyPolicy(store.envelope(),store.publicKey());assert.equal(policy.version,2);
|
||||
const report={actualAdministratorUi:true,explicitDeviceAssignments:true,employeeDeveloperFlags:true,websiteRestrictions:true,noNode:true,cspPreserved:true,...(config.requireIndependentPolicyApproval?{operatorProposalLeftPolicyUnchanged:true,exactProfileReviewed:true,distinctAdministratorApproval:true}:{effectivePreview:true})};mkdirSync('artifacts',{recursive:true});writeFileSync(config.requireIndependentPolicyApproval?'artifacts/profiles-review-admin-proof.json':'artifacts/profiles-admin-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}finally{
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-profiles-admin-smoke-'))throw Error('Unsafe proof cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Finite native fixture: synthetic teaching data, real signed policy and CPU model.
|
||||
const {app,BrowserWindow,clipboard,ClipboardItem}=require('electron');
|
||||
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
|
||||
const assert=require('node:assert/strict');
|
||||
app.setPath('userData',process.env.SAFETYNET_SEMANTIC_PROFILE);
|
||||
app.setPath('sessionData',process.env.SAFETYNET_SEMANTIC_PROFILE);
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ const inspectorPort=await new Promise((resolve,reject)=>{
|
||||
const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.brand={name:'Acme Secure Browser',color:'#245fbc'};p.dlp.rules=[{id:'secret-roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'smoke-user');
|
||||
p.evidence={enabled:true,captureDownloads:true};p.browser.allowDownloads=true;p.security.yara={enabled:true,rules:[{id:'test-marker',name:'Controlled malicious marker',source:'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}],maxScanBytes:1048576};p.agents={enabled:true,scopes:['sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'],allowedHosts:['127.0.0.1'],allowSensitiveRead:false};if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);
|
||||
const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,evidenceKeyFile,requirePQManagement:true,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.on('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
|
||||
const evidenceWire=[];management.on('request',(req,res)=>{if(!req.url.startsWith('/api/device/channel/evidence'))return;const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{const text=Buffer.concat(chunks).toString('utf8');evidenceWire.push({route:req.url,containsMetadata:['safe.bin','blocked.bin','sourceUrl','sha256'].some(value=>text.includes(value))});});});
|
||||
const evidenceWire=[];management.on('request',(req,_res)=>{if(!req.url.startsWith('/api/device/channel/evidence'))return;const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{const text=Buffer.concat(chunks).toString('utf8');evidenceWire.push({route:req.url,containsMetadata:['safe.bin','blocked.bin','sourceUrl','sha256'].some(value=>text.includes(value))});});});
|
||||
|
||||
const child=spawn(electron,['scripts/electron-smoke.cjs'],{stdio:'inherit',windowsHide:true,env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_SINK:p.browser.homeUrl,SAFETYNET_SMOKE_SECONDARY:`http://127.0.0.2:${secondary.address().port}`,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_PUBLIC_UPLOAD:publicUploadFile,SAFETYNET_SMOKE_SECRET_UPLOAD:secretUploadFile}});
|
||||
const timeout=setTimeout(()=>child.kill(),240000);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,readFile,writeFile,copyFile,mkdir,readdir,rm,access} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {verifyPackagedFuses} from './package-fuses.mjs';
|
||||
const root=path.resolve('dist/win-unpacked'),executable=path.join(root,'SafetyNet.exe');
|
||||
let currentStage='initialization';
|
||||
function run(stage,file,args,extra={}){currentStage=stage;console.log(`Package fuse proof: ${stage}`);return new Promise((resolve,reject)=>{const env={...process.env,...extra};delete env.NODE_OPTIONS;if(extra.NODE_OPTIONS)env.NODE_OPTIONS=extra.NODE_OPTIONS;delete env.ELECTRON_RUN_AS_NODE;if(extra.ELECTRON_RUN_AS_NODE)env.ELECTRON_RUN_AS_NODE=extra.ELECTRON_RUN_AS_NODE;const child=spawn(file,args,{env,windowsHide:true,stdio:['ignore','pipe','pipe']});let output='',timedOut=false;const timer=setTimeout(()=>{timedOut=true;child.kill();},15000);child.on('error',error=>{clearTimeout(timer);reject(error);});const append=b=>{output=(output+b).slice(-16384);};child.stdout.on('data',append);child.stderr.on('data',append);child.on('close',async code=>{clearTimeout(timer);const diagnostic={stage,code,timedOut,output};await writeFile('artifacts/package-fuses-last-stage.json',JSON.stringify(diagnostic,null,2));if(timedOut)reject(Error(`Finite package fuse proof timed out during ${stage}; bounded diagnostic persisted`));else resolve({code,output});});});}
|
||||
|
||||
const fixtureRoot=path.resolve('artifacts/package-fuse-fixtures');await mkdir(fixtureRoot,{recursive:true});const directory=await mkdtemp(path.join(fixtureRoot,'case-'));
|
||||
try{
|
||||
currentStage='fuses-and-integrity';const fuses=await verifyPackagedFuses(executable,path.join(root,'resources/app.asar'));
|
||||
const args=[`--agent-connection=${path.join(directory,'missing-connection.json')}`,'--agent-action={"action":"sessions","args":{}}'];
|
||||
const baseline=await run('baseline-cli',executable,args);assert.equal(baseline.code,1);assert.match(baseline.output,/Local agent action failed/);
|
||||
const nodeMode=await run('node-mode',executable,args,{ELECTRON_RUN_AS_NODE:'1'});assert.equal(nodeMode.code,1);assert.match(nodeMode.output,/Local agent action failed/,'RUN_AS_NODE altered normal entrypoint');
|
||||
const marker=path.join(directory,'node-options-ran'),preload=path.join(directory,'injection.cjs');await writeFile(preload,`require('node:fs').writeFileSync(${JSON.stringify(marker)},'injected')`);
|
||||
const injected=await run('node-options-injection',executable,args,{NODE_OPTIONS:`--require=${JSON.stringify(preload)}`});assert.equal(injected.code,1);assert.match(injected.output,/Local agent action failed/);await assert.rejects(access(marker),{code:'ENOENT'});
|
||||
const inspector=await run('cli-inspector',executable,[...args,'--inspect=127.0.0.1:0']);assert.notEqual(inspector.code,0);assert.doesNotMatch(inspector.output,/Debugger listening|DevTools listening/);
|
||||
for(const flag of ['--ignore-certificate-errors','--ignore-certificate-errors-spki-list=synthetic-fixture','--allow-insecure-localhost','--disable-features=ConnectionAllowlists','-disable-features=ConnectionAllowlists','/disable-features=ConnectionAllowlists','/DISABLE-FEATURES=ConnectionAllowlists',' --disable-features=ConnectionAllowlists ','\u0085/disable-features=ConnectionAllowlists\u0085','--disable-blink-features=WebTransport','--enable-experimental-web-platform-features']){const rejected=await run('unsafe-startup-'+flag.split('=')[0].slice(2),executable,[...args,flag]);assert.equal(rejected.code,1);assert.match(rejected.output,/Managed browser startup denied/);assert.doesNotMatch(rejected.output,/Local agent action failed|Debugger listening|DevTools listening/);}
|
||||
const copied=path.join(directory,'copied');await mkdir(path.join(copied,'resources'),{recursive:true});for(const entry of await readdir(root,{withFileTypes:true}))if(entry.isFile())await copyFile(path.join(root,entry.name),path.join(copied,entry.name));
|
||||
const archive=await readFile(path.join(root,'resources/app.asar'));const original=Buffer.from(archive);const index=archive.indexOf(Buffer.from('apps'));assert.ok(index>16&&index<archive.readUInt32LE(12)+16,'No safe header tamper fixture offset');archive[index]^=1;await writeFile(path.join(copied,'resources/app.asar'),archive);
|
||||
const tampered=await run('tampered-asar',path.join(copied,'SafetyNet.exe'),args);assert.notEqual(tampered.code,0);assert.match(tampered.output,/integrity|hash.*mismatch/i,'Tampered archive did not fail with integrity evidence');assert.doesNotMatch(tampered.output,/Local agent action failed/,'Tampered application reached its entrypoint');
|
||||
assert.ok(original.equals(await readFile(path.join(root,'resources/app.asar'))),'Real packaged archive changed');
|
||||
const report={fuses,nodeModeDisabled:true,nodeOptionsInjectionIgnored:true,noInspectorListener:true,unsafeStartupOverridesRejected:true,embeddedAsarHeaderIntegrity:true,tamperedCopiedArchiveRejected:true,originalPackageUnmodified:true};await writeFile('artifacts/package-fuses-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report));
|
||||
}catch(error){await writeFile('artifacts/package-fuses-failure.json',JSON.stringify({stage:currentStage,message:error.message},null,2));throw error;
|
||||
// eslint-disable-next-line no-unsafe-finally -- Unsafe cleanup paths must fail closed before deletion.
|
||||
}finally{const target=path.resolve(directory);if(!target.startsWith(fixtureRoot+path.sep)||path.dirname(target)!==fixtureRoot)throw Error('Unsafe fuse fixture cleanup');await rm(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -83,6 +83,7 @@ try{
|
||||
console.log('Packaged security sources, Windows upload handle lease, Windows-contained inspector, offline EmbeddingGemma 2 CPU inference, legacy and individual Windows agent helpers and bundled DPAPI runtime passed.');
|
||||
}finally{
|
||||
await service?.close();
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
if(!temporary.startsWith(path.join(os.tmpdir(),'safetynet-package-')))throw Error('Unexpected temporary directory');
|
||||
await fs.rm(temporary,{recursive:true,force:true});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const {app,BrowserWindow,session}=require('electron');
|
||||
const {writeFileSync}=require('node:fs');
|
||||
// This fixed helper is serialized into native Chromium Window and Worker realms.
|
||||
/* global WebTransport */
|
||||
app.disableHardwareAcceleration();app.enableSandbox();
|
||||
const mode=process.env.SAFETYNET_WT_MODE;
|
||||
if(mode==='disable-quic')app.commandLine.appendSwitch('disable-quic');
|
||||
if(mode==='disable-blink')app.commandLine.appendSwitch('disable-blink-features','WebTransport');
|
||||
async function sendWebTransport(target,hash){
|
||||
const outcome={api:typeof WebTransport==='function',supportsReliableOnly:typeof WebTransport==='function'&&WebTransport.supportsReliableOnly===true,connected:false,sent:false,error:''};let transport;
|
||||
try{transport=new WebTransport(target,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('fixture-timeout')),6000))]);outcome.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER');const datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();const stream=await transport.createUnidirectionalStream();const writer=stream.getWriter();await writer.write(bytes);await writer.close();outcome.sent=true;await new Promise(resolve=>setTimeout(resolve,300));}catch(error){outcome.error=error.name+':'+error.message;}finally{transport?.close();}return outcome;
|
||||
}
|
||||
app.on('login',(event,_contents,_details,authInfo,callback)=>{if(mode==='private-relay'&&authInfo.isProxy&&authInfo.host==='127.0.0.1'&&Number(authInfo.port)===Number(process.env.SAFETYNET_WT_PROXY.split(':').at(-1))){event.preventDefault();callback(process.env.SAFETYNET_WT_USERNAME,process.env.SAFETYNET_WT_PASSWORD);}});
|
||||
app.whenReady().then(async()=>{
|
||||
const target=process.env.SAFETYNET_WT_TARGET,hash=process.env.SAFETYNET_WT_HASH;let events=0;
|
||||
if(['forced-proxy','private-relay'].includes(mode))await session.defaultSession.setProxy({mode:'fixed_servers',proxyRules:process.env.SAFETYNET_WT_PROXY,proxyBypassRules:'<-loopback>'});
|
||||
session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{events++;callback({cancel:true});});
|
||||
const win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});
|
||||
await win.loadURL(process.env.SAFETYNET_WT_PAGE);
|
||||
const hashBytes=Array.from(Buffer.from(hash,'hex')),result=await win.webContents.executeJavaScript(`(${sendWebTransport.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);
|
||||
const workerSource=`const send=${sendWebTransport.toString()};onmessage=async event=>{postMessage(await send(event.data.target,event.data.hash));close();};`;
|
||||
const worker=await win.webContents.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(workerSource)}],{type:'text/javascript'}));const worker=new Worker(url);const timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('worker-fixture-timeout'));},10000);worker.onmessage=event=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(event.data);};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('worker-fixture-failed'));};worker.postMessage({target:${JSON.stringify(target+'/worker')},hash:${JSON.stringify(hashBytes)}});})`);
|
||||
const ordinaryPageLoaded=await win.webContents.executeJavaScript("document.querySelector('h1')?.textContent==='Local WebTransport receiver proof'");
|
||||
writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({...result,worker,ordinaryPageLoaded,mode,resolvedProxy:await session.defaultSession.resolveProxy(target),webRequestEvents:events,chromium:process.versions.chrome,electron:process.versions.electron}));win.destroy();app.quit();
|
||||
}).catch(error=>{console.error(error.stack);app.exit(1);});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer,request} from 'node:http';
|
||||
import {mkdtempSync,readFileSync,writeFileSync} from 'node:fs';
|
||||
import {resolve,join} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
const require=createRequire(import.meta.url),root=mkdtempSync(resolve('artifacts/webtransport-')),env={...process.env};delete env.ELECTRON_RUN_AS_NODE;
|
||||
const python=spawn('python',['scripts/webtransport-receiver.py',root],{env:{...env,PYTHONPATH:resolve('artifacts/webtransport-python')},windowsHide:true,stdio:['ignore','pipe','pipe']});let errors='';python.stderr.on('data',b=>errors+=b);
|
||||
const metadata=await new Promise((resolve,reject)=>{let text='';const timer=setTimeout(()=>reject(Error('WebTransport receiver startup timeout: '+errors)),15000);python.stdout.on('data',b=>{text+=b;const line=text.split('\n')[0];try{const result=JSON.parse(line);clearTimeout(timer);resolve(result);}catch{}});python.once('exit',()=>{clearTimeout(timer);reject(Error('Receiver exited: '+errors));});});
|
||||
const page=createServer((_req,res)=>{res.setHeader('content-type','text/html');res.end('<!doctype html><h1>Local WebTransport receiver proof</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>page.once('listening',r));
|
||||
let forwarded=0;const proxy=createServer((req,res)=>{const target=new URL(req.url);if(target.hostname!=='127.0.0.1'||Number(target.port)!==page.address().port){res.writeHead(403);res.end();return;}forwarded++;const outgoing=request(target,{method:req.method,headers:req.headers},incoming=>{res.writeHead(incoming.statusCode,incoming.headers);incoming.pipe(res);});outgoing.on('error',()=>{res.writeHead(502);res.end();});req.pipe(outgoing);}).listen(0,'127.0.0.1');await new Promise(r=>proxy.once('listening',r));
|
||||
const outcomes=[];
|
||||
let relayAuthorizations=0;const authority={version:1},relay=await require('../apps/browser/local-dlp-relay.cjs').startLocalDlpRelay({authorize:async()=>{relayAuthorizations++;return authority;},isCurrent:a=>a===authority,resolveUpstream:async()=> 'DIRECT',resolveAddress:async()=> '127.0.0.1'});
|
||||
try{
|
||||
for(const mode of ['baseline','disable-quic','disable-blink','forced-proxy','private-relay']){
|
||||
const before=await (await fetch(`http://127.0.0.1:${metadata.statsPort}/facts`)).json(),output=join(root,mode+'.json');
|
||||
const forwardedBefore=forwarded;
|
||||
const credentials=relay.credentials(),relayBefore=relayAuthorizations;
|
||||
const child=spawn(require('electron'),['scripts/webtransport-native-probe.cjs'],{env:{...env,SAFETYNET_WT_MODE:mode,SAFETYNET_WT_USERNAME:credentials.username,SAFETYNET_WT_PASSWORD:credentials.password,SAFETYNET_WT_PROXY:mode==='private-relay'?relay.proxyRules:`127.0.0.1:${proxy.address().port}`,SAFETYNET_WT_TARGET:`https://127.0.0.1:${metadata.quicPort}/webtransport`,SAFETYNET_WT_PAGE:`http://127.0.0.1:${page.address().port}/`,SAFETYNET_WT_HASH:metadata.hash,SAFETYNET_WT_OUTPUT:output},windowsHide:true,stdio:['ignore','pipe','pipe']});let log='';child.stdout.on('data',b=>log+=b);child.stderr.on('data',b=>log+=b);const timer=setTimeout(()=>child.kill(),20000),code=await new Promise(r=>child.once('exit',r));clearTimeout(timer);if(code!==0)throw Error(`Native ${mode} exited ${code}: ${log}`);
|
||||
const after=await (await fetch(`http://127.0.0.1:${metadata.statsPort}/facts`)).json(),result=JSON.parse(readFileSync(output,'utf8'));outcomes.push({...result,forwardedHTTP:mode==='private-relay'?relayAuthorizations-relayBefore:forwarded-forwardedBefore,receiver:{connections:after.connections-before.connections,datagrams:after.datagrams-before.datagrams,streams:after.streams-before.streams,bytes:after.bytes-before.bytes,workerBytes:after.workerBytes-before.workerBytes,packets:after.packets-before.packets,markerReceived:after.bytes>before.bytes&&after.marker}});
|
||||
}
|
||||
for(const result of outcomes){assert.equal(result.ordinaryPageLoaded,true);if(['baseline','disable-quic','disable-blink'].includes(result.mode)){assert.equal(result.sent,true);assert.equal(result.worker.sent,true);assert.ok(result.receiver.bytes>=132);assert.ok(result.receiver.workerBytes>=66);assert.equal(result.webRequestEvents,0);}else{assert.equal(result.sent,false);assert.equal(result.worker.sent,false);assert.equal(result.receiver.packets,0);assert.equal(result.receiver.bytes,0);assert.ok(result.forwardedHTTP>0);}}
|
||||
const report={actualElectron:true,localPinnedReceiver:true,serverVersion:metadata.aioquic,outcomes};writeFileSync('artifacts/webtransport-native-proof.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));
|
||||
}finally{page.closeAllConnections();proxy.closeAllConnections();await Promise.all([new Promise(r=>page.close(r)),new Promise(r=>proxy.close(r)),relay.close()]);python.kill();}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Finite loopback-only HTTP/3 WebTransport receiver for native security proof."""
|
||||
import asyncio, datetime, hashlib, ipaddress, json, os, pathlib, sys
|
||||
from aioquic.asyncio import QuicConnectionProtocol, serve
|
||||
from aioquic.h3.connection import H3Connection, H3_ALPN
|
||||
from aioquic.h3.events import HeadersReceived, DatagramReceived, WebTransportStreamDataReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ProtocolNegotiated
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
facts = {"packets": 0, "connections": 0, "datagrams": 0, "streams": 0, "bytes": 0, "workerBytes": 0, "marker": False}
|
||||
marker = b"SYNTHETIC_WEBTRANSPORT_DLP_MARKER"
|
||||
class Receiver(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = None
|
||||
self.worker_sessions = set()
|
||||
def datagram_received(self, data, addr):
|
||||
facts["packets"] += 1
|
||||
super().datagram_received(data, addr)
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, ProtocolNegotiated):
|
||||
self.http = H3Connection(self._quic, enable_webtransport=True)
|
||||
if not self.http:
|
||||
return
|
||||
for item in self.http.handle_event(event):
|
||||
if isinstance(item, HeadersReceived):
|
||||
headers = dict(item.headers)
|
||||
if headers.get(b":method") == b"CONNECT" and headers.get(b":protocol") == b"webtransport":
|
||||
facts["connections"] += 1
|
||||
if headers.get(b":path", b"").endswith(b"/worker"):
|
||||
self.worker_sessions.add(item.stream_id)
|
||||
self.http.send_headers(item.stream_id, [(b":status", b"200"), (b"sec-webtransport-http3-draft", b"draft02")])
|
||||
self.transmit()
|
||||
elif isinstance(item, (DatagramReceived, WebTransportStreamDataReceived)):
|
||||
facts["datagrams" if isinstance(item, DatagramReceived) else "streams"] += 1
|
||||
facts["bytes"] += len(item.data)
|
||||
session_id = item.stream_id if isinstance(item, DatagramReceived) else item.session_id
|
||||
if session_id in self.worker_sessions:
|
||||
facts["workerBytes"] += len(item.data)
|
||||
facts["marker"] |= marker in item.data
|
||||
|
||||
async def main():
|
||||
root = pathlib.Path(sys.argv[1]).resolve()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
|
||||
cert = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-datetime.timedelta(minutes=1)).not_valid_after(now+datetime.timedelta(days=7)).add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False).sign(key, hashes.SHA256())
|
||||
cert_file, key_file = root / "certificate.pem", root / "key.pem"
|
||||
cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
key_file.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
config = QuicConfiguration(is_client=False, alpn_protocols=H3_ALPN, max_datagram_frame_size=65536)
|
||||
config.load_cert_chain(str(cert_file), str(key_file))
|
||||
server = await serve("127.0.0.1", 0, configuration=config, create_protocol=Receiver)
|
||||
async def stats(reader, writer):
|
||||
await reader.read(8192)
|
||||
body = json.dumps(facts).encode()
|
||||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
control = await asyncio.start_server(stats, "127.0.0.1", 0)
|
||||
print(json.dumps({"quicPort": server._transport.get_extra_info("sockname")[1], "statsPort": control.sockets[0].getsockname()[1], "hash": hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).hexdigest(), "aioquic": __import__("aioquic").__version__}), flush=True)
|
||||
await asyncio.sleep(180)
|
||||
server.close()
|
||||
control.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
// A second Electron process reads its original DPAPI enrollment, never reenrolls.
|
||||
const {app,BrowserWindow,session,clipboard,ClipboardItem}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),https=require('node:https'),{X509Certificate}=require('node:crypto');
|
||||
assert.equal(process.env.SAFETYNET_SMOKE_CODE,undefined,'Restart must not receive an enrollment code');app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();
|
||||
const origin=process.env.SAFETYNET_SMOKE_ORIGIN,base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN},secret='MVP Company Forecast 2030',fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256,fromPartition=session.fromPartition.bind(session);
|
||||
session.fromPartition=(...args)=>{const current=fromPartition(...args);if(args[0]==='persist:safetynet-managed')current.setCertificateVerifyProc((req,callback)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}callback(valid?0:-2);});return current;};
|
||||
let actualDiagnostics;const moduleDiagnostics=require('../apps/browser/diagnostics.cjs'),createDiagnostics=moduleDiagnostics.createDiagnostics;moduleDiagnostics.createDiagnostics=(...args)=>{const value=createDiagnostics(...args);actualDiagnostics=value;return value;};require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const result=await fn();if(result)return result;await wait(100);}throw Error('Restarted native MVP timeout');}
|
||||
const privateFacts=()=>new Promise((resolve,reject)=>{const request=https.get(origin+'/facts',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(JSON.parse(body)));});request.on('error',reject);});
|
||||
app.whenReady().then(async()=>{let stage='persisted startup',page,original=[];const checks=[];try{for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);original.push(new ClipboardItem(data));}const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const ui=async()=>{const result=await win.webContents.executeJavaScript('window.safetynet.state()');assert.equal(result.ok,true);return result.data;};const initial=await until(async()=>{const current=await ui();return current.ready&¤t.enrolled&¤t;});assert.equal(initial.deviceId,process.env.SAFETYNET_SMOKE_EXPECT_DEVICE);assert.equal(initial.policyVersion,Number(process.env.SAFETYNET_SMOKE_EXPECT_POLICY));assert.notEqual(process.pid,Number(process.env.SAFETYNET_SMOKE_FIRST_PID));page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');checks.push('same-dpapi-device-current-policy-new-process');
|
||||
const before=await privateFacts(),key=async keyCode=>{win.focus();page.focus();page.sendInputEvent({type:'keyDown',keyCode,modifiers:['control']});page.sendInputEvent({type:'keyUp',keyCode,modifiers:['control']});await wait(400);await until(async()=>!(await ui()).clipboardPending);};const editor=async text=>page.executeJavaScript(`(()=>{const e=document.getElementById('editor');e.innerHTML='<p></p>';e.firstChild.textContent=${JSON.stringify(text)};e.focus();const r=document.createRange();r.selectNodeContents(e.firstChild);r.collapse(false);const s=getSelection();s.removeAllRanges();s.addRange(r);})()`);const value=()=>page.executeJavaScript("document.getElementById('editor').innerText.trimEnd()");
|
||||
stage='post-restart public paste submit';await editor('');await clipboard.writeText('Public restarted AI prompt');await key('V');assert.equal(await value(),'Public restarted AI prompt');assert.equal(await page.executeJavaScript("window.mvpSubmit('editor')"),true);assert.equal((await privateFacts()).receiver.posts,before.receiver.posts+1);checks.push('restarted-public-paste-submit');stage='post-restart confidential paste held';await editor('Public preserved restart baseline');await clipboard.writeText(secret);await key('V');assert.equal(await value(),'Public preserved restart baseline');assert.equal(await page.executeJavaScript("window.mvpSubmit('editor')"),true);assert.equal((await privateFacts()).receiver.posts,before.receiver.posts+2);assert.equal((await privateFacts()).receiver.secretPosts,0);checks.push('restarted-confidential-paste-denied');stage='post-restart typed confidential body denied';assert.equal(await page.executeJavaScript(`fetch('/receiver/typed',{method:'POST',body:${JSON.stringify(secret)}}).then(r=>r.ok).catch(()=>false)`),false);assert.equal((await privateFacts()).receiver.posts,before.receiver.posts+2);checks.push('restarted-confidential-body-denied');
|
||||
stage='post-restart public file allowed';const doc=await page.debugger.sendCommand('DOM.getDocument',{}),node=await page.debugger.sendCommand('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});const upload=async file=>{await page.debugger.sendCommand('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[file]});return page.executeJavaScript("(()=>{const body=new FormData();body.append('file',document.getElementById('file').files[0]);return fetch('/receiver/file',{method:'POST',body}).then(r=>r.ok).catch(()=>false)})()");};assert.equal(await upload(process.env.SAFETYNET_SMOKE_PUBLIC_FILE),true);let after=await privateFacts();assert.equal(after.receiver.posts,before.receiver.posts+3);assert.equal(after.receiver.filePosts,before.receiver.filePosts+1);assert.equal(after.receiver.exactPublicFile,before.receiver.exactPublicFile+1);stage='post-restart confidential file denied';assert.equal(await upload(process.env.SAFETYNET_SMOKE_SECRET_FILE),false);after=await privateFacts();assert.equal(after.receiver.posts,before.receiver.posts+3);assert.equal(after.receiver.secretPosts,0);checks.push('restarted-public-secret-ntfs-upload');
|
||||
stage='post-restart audit and traces';await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=Number(process.env.SAFETYNET_SMOKE_AFTER_EVENT);for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);for(const row of rows){assert.equal(row.safetynet.policy_version,initial.policyVersion,'Restart audit policy binding changed');assert.equal(row.host.id,initial.deviceId);assert.ok(!JSON.stringify(row).includes(secret));events.push(row);}if(rows.length<1000)break;const next=Number(response.headers.get('x-safetynet-next-cursor'));assert.ok(next>cursor);cursor=next;}assert.ok(events.some(e=>e.event.action==='browser.started'),'New browser process start was not audited');const traces=[];for(const action of ['clipboard.paste','clipboard.block','upload.authorized','upload.block']){const event=events.filter(e=>e.event.action===action&&e.trace?.id).at(-1);assert.ok(event,action+' restart event absent');const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.length);assert.ok(!JSON.stringify(trace).includes(secret));const expected=action.startsWith('clipboard')?'browser.clipboard':'browser.request';assert.ok(trace.spans.some(s=>s.name===expected));if(action.startsWith('upload.'))assert.ok(trace.spans.some(s=>s.name==='browser.upload.lease'),'Restart native file lease trace missing');const byId=new Map(trace.spans.map(s=>[s.spanId,s]));if(action.startsWith('upload.')){for(const operation of ['text.local','text.enterprise']){const normalized=trace.spans.find(s=>s.operation===operation);assert.ok(normalized,'Normalized URL stage absent');let current=normalized,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name==='browser.request'){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Normalized URL parent correlation absent');}}for(const stage of trace.spans.filter(s=>s.name==='browser.upload.lease'||s.name==='browser.upload.part'||s.name==='browser.inspect.local')){let current=stage,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name===expected){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Restart span parent binding absent');}traces.push({action,traceId:event.trace.id,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))});}const status=actualDiagnostics.status();assert.equal(status.enabled,true);assert.equal(status.failedExports,0);assert.equal(status.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);
|
||||
fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-restart-native-proof.json'),JSON.stringify({actualNewNativeProcess:true,firstProcessId:Number(process.env.SAFETYNET_SMOKE_FIRST_PID),restartedProcessId:process.pid,enrollmentCodeAbsent:true,persistedDeviceId:initial.deviceId,policyVersion:initial.policyVersion,checks,receiverBefore:before.receiver,receiverAfter:after.receiver,scannerBefore:before.scanner,scannerAfter:after.scanner,traces,diagnostics:status,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Windows DLP MVP actual process restart proof passed');if(original.length)await clipboard.write(original);else await clipboard.clear();require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){let facts;try{facts=await privateFacts();await actualDiagnostics?.flush();}catch{}fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-restart-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,checks,facts,diagnostics:actualDiagnostics?.status()},null,2));console.error('Restarted Windows MVP failed at '+stage+': '+error.name);try{if(original.length)await clipboard.write(original);else await clipboard.clear();}catch{}if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
const {app,BrowserWindow,session,clipboard,ClipboardItem}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),https=require('node:https'),{X509Certificate}=require('node:crypto');
|
||||
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();let actualDiagnostics;const diagnosticsModule=require('../apps/browser/diagnostics.cjs'),createDiagnostics=diagnosticsModule.createDiagnostics;diagnosticsModule.createDiagnostics=(...args)=>{const instance=createDiagnostics(...args);actualDiagnostics=instance;return instance;};require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},secret='MVP Company Forecast 2030',checks=[],editorCaretFacts=[];
|
||||
async function until(fn){for(let i=0;i<250;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native Windows DLP fixture timeout');}
|
||||
app.whenReady().then(async()=>{let page,readFailureFacts,originalClipboard=[],stage='startup';try{for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);originalClipboard.push(new ClipboardItem(data));}const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}callback(valid?0:-2);});const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;};
|
||||
const privateGET=route=>new Promise((resolve,reject)=>{const request=https.get(origin+route,{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(route==='/facts'?JSON.parse(body):true));});request.on('error',reject);});
|
||||
readFailureFacts=()=>privateGET('/facts');stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');let loadGeneration=0;page.on('did-finish-load',()=>loadGeneration++);const navigate=async()=>{const before=loadGeneration;await ui('navigate',origin+'/workspace');await until(()=>loadGeneration>before&&!page.isLoading()&&page.getURL()===origin+'/workspace');win.focus();page.focus();await wait(200);};
|
||||
const change=async modify=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;modify(p);p.version++;const generation=loadGeneration,proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approval=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approval.status,200);await until(async()=>{const s=await ui('state');return s.ready&&s.policyVersion===p.version;});await until(()=>loadGeneration>generation&&!page.isLoading());win.focus();page.focus();await wait(200);};
|
||||
const key=async(code,shift=false)=>{win.focus();page.focus();page.sendInputEvent({type:'keyDown',keyCode:code,modifiers:shift?['shift']:['control']});page.sendInputEvent({type:'keyUp',keyCode:code,modifiers:shift?['shift']:['control']});await wait(400);await until(async()=>!(await ui('state')).clipboardPending);};
|
||||
const setField=(id,text='')=>page.executeJavaScript(`(()=>{const e=document.getElementById(${JSON.stringify(id)});if(e.isContentEditable){e.innerHTML='<p><br></p>';if(${JSON.stringify(text)})e.firstChild.textContent=${JSON.stringify(text)};e.focus();const range=document.createRange();range.selectNodeContents(e.firstChild);range.collapse(!e.firstChild.textContent);const s=getSelection();s.removeAllRanges();s.addRange(range);}else{e.value=${JSON.stringify(text)};e.focus();e.setSelectionRange(e.value.length,e.value.length);}})()`);
|
||||
const clickEditor=async()=>{const point=await page.executeJavaScript("(()=>{const r=document.getElementById('editor').getBoundingClientRect();return {x:Math.round(r.left+8),y:Math.round(r.top+10)}})()");page.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point});page.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point});await wait(80);const facts=await page.executeJavaScript("(()=>{const e=document.getElementById('editor'),s=getSelection(),r=s.rangeCount===1?s.getRangeAt(0):null,p=e.firstChild;return {focused:document.activeElement===e,collapsed:!!r?.collapsed,startOwned:!!r&&e.contains(r.startContainer),endOwned:!!r&&e.contains(r.endContainer),startInParagraph:!!r&&p.contains(r.startContainer),endInParagraph:!!r&&p.contains(r.endContainer)}})()");assert.ok(facts.focused&&facts.collapsed&&facts.startOwned&&facts.endOwned);editorCaretFacts.push({nativeClick:true,...facts});};
|
||||
const value=id=>page.executeJavaScript(`(()=>{const e=document.getElementById(${JSON.stringify(id)});return e.isContentEditable?e.innerText.trimEnd():e.value;})()`);
|
||||
const post=(kind,text)=>page.executeJavaScript(`fetch(${JSON.stringify('/receiver/'+kind)},{method:'POST',headers:{'content-type':'text/plain'},body:${JSON.stringify(text)}}).then(r=>r.ok).catch(()=>false)`);
|
||||
stage='typed public body allowed';let before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('typed','Public typed prompt'),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);checks.push('public-body');stage='typed confidential body blocked';before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('typed',secret),false);assert.equal((await privateGET('/facts')).receiver.posts,before);checks.push('secret-body');
|
||||
for(const id of ['input','textarea','editor']){stage=id+' public paste to actual receiver';await setField(id);if(id==='editor')await clickEditor();const text='Public '+id+' prompt for AI';await clipboard.writeText(text);await key('V');stage=id+' public paste commit';assert.equal(await value(id),text,'Public AI editor paste did not commit');before=(await privateGET('/facts')).receiver.posts;if(id==='input'){stage='input native form dispatch';const loading=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([loading,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before+1);await navigate();}else{assert.equal(await page.executeJavaScript(`window.mvpSubmit(${JSON.stringify(id)})`),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);}checks.push(id+'-public-paste-submit');
|
||||
stage=id+' confidential paste cannot reach receiver';const baseline='Public preserved '+id;await setField(id,baseline);await clipboard.writeText(secret);await key('V');assert.equal(await value(id),baseline,'Confidential paste changed the AI editor');before=(await privateGET('/facts')).receiver.posts;assert.equal(await page.executeJavaScript(`window.mvpSubmit(${JSON.stringify(id)})`),true);const facts=await privateGET('/facts');assert.equal(facts.receiver.posts,before+1);assert.equal(facts.receiver.secretPosts,0);checks.push(id+'-secret-paste-denied');}
|
||||
stage='plain part of HTML clipboard stays inert';await setField('editor');await clickEditor();await clipboard.write([new ClipboardItem({'text/plain':new Blob(['Public HTML-source prompt'],{type:'text/plain'}),'text/html':new Blob(['<p>Public HTML-source prompt</p><img id="mvp-html-must-not-render" src="/html-clipboard-network">'],{type:'text/html'})})]);await key('V');assert.equal(await value('editor'),'Public HTML-source prompt');assert.equal(await page.executeJavaScript("!!document.querySelector('#editor img,#mvp-html-must-not-render')"),false);before=(await privateGET('/facts')).receiver.posts;assert.equal(await page.executeJavaScript("window.mvpSubmit('editor')"),true);assert.equal((await privateGET('/facts')).receiver.posts,before+1);stage='confidential plain part of HTML clipboard blocked';await setField('editor','Public HTML baseline');await clipboard.write([new ClipboardItem({'text/plain':new Blob([secret],{type:'text/plain'}),'text/html':new Blob(['<p>Public decoy</p>'],{type:'text/html'})})]);await key('V');assert.equal(await value('editor'),'Public HTML baseline');checks.push('html-clipboard-inert-plain-part-dlp');
|
||||
stage='native form typed public allowed';await setField('input','Public typed standard form');before=(await privateGET('/facts')).receiver.posts;let stopped=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([stopped,new Promise((_,reject)=>setTimeout(()=>reject(Error('Native form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before+1);await navigate();checks.push('public-standard-form');stage='native form typed confidential denied';await setField('input',secret);before=(await privateGET('/facts')).receiver.posts;stopped=new Promise(resolve=>page.once('did-stop-loading',resolve));assert.equal(await page.executeJavaScript("(()=>{setTimeout(()=>document.getElementById('prompt-form').requestSubmit(),0);return true})()"),true);await Promise.race([stopped,new Promise((_,reject)=>setTimeout(()=>reject(Error('Blocked form did not terminate')),15000))]);assert.equal((await privateGET('/facts')).receiver.posts,before);await navigate();checks.push('secret-standard-form');
|
||||
const selectCopy=text=>page.executeJavaScript(`(()=>{const e=document.getElementById('copy-source');e.textContent=${JSON.stringify(text)};const range=document.createRange();range.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(range);})()`);stage='public native copy';await selectCopy('Public copy sample');await clipboard.writeText('Public clipboard sentinel');await key('C');assert.equal(await clipboard.readText(),'Public copy sample');stage='confidential copy holds OS clipboard';await clipboard.writeText('Public clipboard sentinel');await selectCopy(secret);await key('C');assert.equal(await clipboard.readText(),'Public clipboard sentinel');stage='confidential cut preserves source';await setField('input',secret);await page.executeJavaScript("document.getElementById('input').select()");await key('X');assert.equal(await clipboard.readText(),'Public clipboard sentinel');assert.equal(await value('input'),secret);checks.push('copy-cut-secret-denied');stage='public native cut';await setField('textarea','Public cut sample');await page.executeJavaScript("document.getElementById('textarea').select()");await key('X');assert.equal(await clipboard.readText(),'Public cut sample');assert.equal(await value('textarea'),'');checks.push('public-copy-cut');
|
||||
stage='native NTFS public file';const doc=await page.debugger.sendCommand('DOM.getDocument',{}),node=await page.debugger.sendCommand('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});const upload=async file=>{await page.debugger.sendCommand('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[file]});return page.executeJavaScript("(()=>{const body=new FormData();body.append('file',document.getElementById('file').files[0]);return fetch('/receiver/file',{method:'POST',body}).then(r=>r.ok).catch(()=>false)})()");};assert.equal(await upload(process.env.SAFETYNET_SMOKE_PUBLIC_FILE),true);let facts=await privateGET('/facts');assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.exactPublicFile,1);stage='native NTFS confidential file denied';assert.equal(await upload(process.env.SAFETYNET_SMOKE_SECRET_FILE),false);facts=await privateGET('/facts');assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.secretPosts,0);checks.push('native-file-public-secret');
|
||||
stage='actual local YARA blocks';before=(await privateGET('/facts')).receiver.posts;assert.equal(await post('yara','MVP_YARA_ONLY_BLOCK'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);stage='actual ICAP blocks';assert.equal(await post('icap','MVP_ICAP_ONLY_BLOCK'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);stage='actual configured scanner failure fails closed';await privateGET('/scanner/fail');assert.equal(await post('scanner','Public scanner outage prompt'),false);assert.equal((await privateGET('/facts')).receiver.posts,before);await privateGET('/scanner/normal');checks.push('real-yara-icap-scanner-failure');
|
||||
stage='policy changes while real scan pending deny release';await privateGET('/scanner/gate');before=(await privateGET('/facts')).receiver.posts;const pending=post('race','Public pending scan').catch(()=>false);await until(async()=>(await privateGET('/facts')).scanner.pending);await change(p=>p.dlp.rules.push({id:'mvp-late-classification',label:'Synthetic policy race',kind:'literal',value:'Public pending scan',action:'block'}));await privateGET('/scanner/release');assert.equal(await pending,false);assert.equal((await privateGET('/facts')).receiver.posts,before);await privateGET('/scanner/normal');checks.push('policy-race-denied');
|
||||
stage='central paste disable then re-enable';await change(p=>p.security.clipboard.allowPaste=false);await setField('editor');await clipboard.writeText('Public centrally disabled paste');await key('V');assert.equal(await value('editor'),'');await change(p=>p.security.clipboard.allowPaste=true);await setField('editor');await clipboard.writeText('Public centrally restored paste');await key('V');assert.equal(await value('editor'),'Public centrally restored paste');checks.push('independent-central-paste-control');
|
||||
stage='real correlated diagnostics and SIEM';await wait(1800);const events=[];let after=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${after}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));for(const row of rows){assert.ok(!/MVP Company Forecast 2030|MVP_YARA_ONLY_BLOCK|MVP_ICAP_ONLY_BLOCK/.test(JSON.stringify(row)));events.push(row);}if(rows.length<1000)break;const next=Number(response.headers.get('x-safetynet-next-cursor'));assert.ok(next>after);after=next;}const traceEvidence=[];for(const action of ['clipboard.paste','clipboard.block','upload.authorized','upload.block']){const candidates=events.filter(e=>e.event.action===action&&e.trace?.id);assert.ok(candidates.length,action+' durable audit missing');let found;for(const event of candidates.reverse()){const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();const expected=action.startsWith('clipboard')?'browser.clipboard':'browser.request';if(trace.spans?.some(s=>s.name===expected)){assert.ok(!/MVP Company Forecast 2030|MVP_YARA_ONLY_BLOCK|MVP_ICAP_ONLY_BLOCK/.test(JSON.stringify(trace)));const byId=new Map(trace.spans.map(s=>[s.spanId,s]));if(action.startsWith('upload.')){for(const operation of ['text.local','text.enterprise']){const normalized=trace.spans.find(s=>s.operation===operation);assert.ok(normalized,'Normalized URL stage absent');let current=normalized,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name==='browser.request'){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Normalized URL parent correlation absent');}}if(action==='clipboard.paste'){for(const name of ['browser.inspect.local','browser.inspect.enterprise']){const stage=trace.spans.find(s=>s.name===name);assert.ok(stage,'Clipboard inspection stage missing');let node=stage,linked=false;const seen=new Set();while(node&&!seen.has(node.spanId)){if(node.name==='browser.clipboard'){linked=true;break;}seen.add(node.spanId);node=byId.get(node.parentSpanId);}assert.ok(linked,'Clipboard inspection parent correlation missing');}}if(action==='upload.authorized'){assert.ok(trace.spans.some(s=>s.name==='browser.upload.lease'));for(const s of trace.spans.filter(s=>s.name==='browser.upload.lease'||s.name==='browser.upload.part')){let node=s,linked=false;const seen=new Set();while(node&&!seen.has(node.spanId)){if(node.name==='browser.request'){linked=true;break;}seen.add(node.spanId);node=byId.get(node.parentSpanId);}assert.ok(linked,'File stage parent correlation missing');}}found={action,traceId:event.trace.id,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))};break;}}assert.ok(found,action+' exported trace missing');traceEvidence.push(found);}facts=await privateGET('/facts');assert.equal(facts.receiver.secretPosts,0);assert.equal(facts.receiver.filePosts,1);assert.equal(facts.receiver.exactPublicFile,1);assert.equal(facts.scanner.blocks,1);assert.ok(facts.scanner.failures>=1);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json();await actualDiagnostics.flush();const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.enabled,true);assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);
|
||||
fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-native-proof.json'),JSON.stringify({actualWindows:process.platform==='win32',actualElectron:true,processId:process.pid,deviceId:(await ui('state')).deviceId,policyVersion:(await ui('state')).policyVersion,exactPinnedHTTPS:true,independentReviewedPublication:true,checks,editorCaretFacts,receiver:facts.receiver,scanner:facts.scanner,ordinaryAuditConfidentialFree:true,traceEvidence,summary,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},diagnostics},null,2));console.log('Windows DLP MVP native proof passed');if(originalClipboard.length)await clipboard.write(originalClipboard);else await clipboard.clear();require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){let failureFacts;try{failureFacts=await readFailureFacts?.();await actualDiagnostics?.flush();}catch{}fs.writeFileSync(path.resolve('artifacts/windows-dlp-mvp-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,checks,editorCaretFacts,failureFacts,diagnostics:actualDiagnostics?.status()},null,2));console.error('Windows DLP MVP failed at '+stage+': '+error.name);try{if(originalClipboard.length)await clipboard.write(originalClipboard);else await clipboard.clear();}catch{}if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,27 @@
|
||||
// A bounded, real Windows browser DLP acceptance receiver; never a production bypass.
|
||||
import {createServer} from 'node:https';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,rmSync,readFileSync,writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve,sep} from 'node:path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable');process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry();
|
||||
const temporary=mkdtempSync(join(tmpdir(),'safetynet-windows-dlp-'));mkdirSync('artifacts',{recursive:true});if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-windows-dlp-'))throw Error('Unsafe temporary fixture path');
|
||||
const secret='MVP Company Forecast 2030',yaraMarker='MVP_YARA_ONLY_BLOCK',icapMarker='MVP_ICAP_ONLY_BLOCK',publicFile=Buffer.from('Public Windows DLP file content\n'),files={public:join(temporary,'public.txt'),secret:join(temporary,'secret.txt')};writeFileSync(files.public,publicFile);writeFileSync(files.secret,secret);
|
||||
const receiver={posts:0,secretPosts:0,filePosts:0,exactPublicFile:0,kinds:{}},scanner={scans:0,blocks:0,failures:0,pending:false};let avMode='normal',pendingAV=null;const sockets=new Set();
|
||||
const av=net.createServer(socket=>{sockets.add(socket);socket.on('close',()=>sockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(input.length>4194304){input.fill(0);socket.destroy();return;}if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options){input.fill(0);socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD, REQMOD\r\nAllow: 204\r\nISTag: "mvp-fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}scanner.scans++;const block=input.includes(Buffer.from(icapMarker)),gate=avMode==='gate'&&input.includes(Buffer.from('Public pending scan'));input.fill(0);if(avMode==='fail'){scanner.failures++;socket.end('ICAP/1.0 500 Server Error\r\n\r\n');return;}const reply=()=>socket.end(block?'ICAP/1.0 204 No Content\r\nISTag: "mvp-fixture"\r\nX-Infection-Found: Type=0; Threat=synthetic-mvp;\r\n\r\n':'ICAP/1.0 204 No Content\r\nISTag: "mvp-fixture"\r\n\r\n');if(block)scanner.blocks++;if(gate){scanner.pending=true;pendingAV=()=>{scanner.pending=false;pendingAV=null;reply();};}else reply();});}).listen(0,'127.0.0.1');await new Promise(r=>av.once('listening',r));
|
||||
const page=`<!doctype html><meta charset="utf-8"><title>Public AI workspace</title><style>body{font:18px Arial;padding:24px}input,textarea,[contenteditable]{display:block;width:80%;min-height:44px;border:1px solid #aaa;margin:15px}button{padding:12px}</style><h1>Public AI prompt workspace</h1><form id="prompt-form" method="post" action="/receiver/form"><input id="input" name="prompt" type="text"><button id="form-submit">Send prompt</button></form><textarea id="textarea"></textarea><div id="editor" role="textbox" contenteditable="true"><p><br></p></div><input id="file" type="file"><pre id="copy-source"></pre><script>window.mvpSubmit=(id)=>{const e=document.getElementById(id),text=e.isContentEditable?e.innerText:e.value;return fetch('/receiver/'+id,{method:'POST',headers:{'content-type':'text/plain'},body:text}).then(r=>r.ok).catch(()=>false)};</script>`;
|
||||
// eslint-disable-next-line no-useless-assignment -- Explicitly clear sensitive transient text after use.
|
||||
const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/facts'){res.setHeader('content-type','application/json');res.end(JSON.stringify({receiver,scanner}));return;}if(req.url.startsWith('/scanner/')){const mode=req.url.split('/').at(-1);if(mode==='release')pendingAV?.();else avMode=mode;res.end('ok');return;}if(req.method==='POST'){let chunks=[],size=0;req.on('data',chunk=>{size+=chunk.length;if(size>1048576){req.destroy();return;}chunks.push(chunk);});req.on('end',()=>{receiver.posts++;const body=Buffer.concat(chunks);let decoded=body.toString('utf8');try{decoded=decodeURIComponent(decoded.replace(/\+/g,' '));}catch{}if(decoded.includes(secret)||body.includes(Buffer.from(secret))||decoded.includes(yaraMarker)||decoded.includes(icapMarker))receiver.secretPosts++;const kind=/^\/receiver\/(input|textarea|editor|form|typed|file|race|scanner|yara|icap)$/.exec(req.url)?.[1]||'other';receiver.kinds[kind]=(receiver.kinds[kind]||0)+1;if(kind==='file'){receiver.filePosts++;const boundary=/boundary=(?:"([^"]+)"|([^;]+))/.exec(req.headers['content-type']||'');if(boundary){const start=body.indexOf(Buffer.from('\r\n\r\n')),end=body.indexOf(Buffer.from('\r\n--'+(boundary[1]||boundary[2])),start+4);if(start>=0&&end>=0&&body.subarray(start+4,end).equals(publicFile))receiver.exactPublicFile++;}}decoded='';body.fill(0);for(const chunk of chunks)chunk.fill(0);chunks=[];res.end('accepted');});return;}res.setHeader('content-type','text/html');res.end(page);}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
|
||||
const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');const p=store.get('policy');p.browser.homeUrl=origin+'/workspace';p.browser.allowAgentBackgroundWorkers=false;p.dlp.rules.push({id:'mvp-company-secret',label:'Synthetic company confidential forecast',kind:'literal',value:secret,action:'block'});p.security.yara.enabled=true;p.security.yara.rules=[{id:'mvp-yara-only',name:'Synthetic MVP YARA',source:`rule mvp_yara_only { strings: $a = "${yaraMarker}" condition: $a }`}];p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${av.address().port}/scan`,scanUploads:true,scanDownloads:false,scanClipboard:false,scanAI:false};p.agents.enabled=false;p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'mvp-fixture');
|
||||
const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-mvp-reviewer',role:'admin'}));
|
||||
const executable=createRequire(import.meta.url)('electron'),environment={...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ORIGIN:origin,SAFETYNET_SMOKE_PUBLIC_FILE:files.public,SAFETYNET_SMOKE_SECRET_FILE:files.secret};delete environment.SAFETYNET_SMOKE_CODE;
|
||||
async function runNative(script,extra={}){const child=spawn(executable,[script],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...environment,...extra}});child.stdout.on('data',v=>process.stdout.write(v));child.stderr.on('data',v=>process.stderr.write(v));const deadline=setTimeout(()=>child.kill(),240000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline);return {exit,pid:child.pid};}
|
||||
let exit;try{const first=await runNative('scripts/windows-dlp-mvp-smoke.cjs',{SAFETYNET_SMOKE_CODE:code});exit=first.exit;
|
||||
if(exit===0){const proof=JSON.parse(readFileSync('artifacts/windows-dlp-mvp-native-proof.json','utf8'));writeFileSync('artifacts/windows-dlp-mvp-first-process-native-proof.json',JSON.stringify(proof,null,2));const cursor=store.db.prepare('SELECT COALESCE(MAX(id),0) AS id FROM events').get().id,devicesBefore=store.db.prepare('SELECT COUNT(*) AS count FROM devices').get().count;const restarted=await runNative('scripts/windows-dlp-mvp-restart-smoke.cjs',{SAFETYNET_SMOKE_FIRST_PID:String(proof.processId),SAFETYNET_SMOKE_EXPECT_DEVICE:proof.deviceId,SAFETYNET_SMOKE_EXPECT_POLICY:String(store.get('policy').version),SAFETYNET_SMOKE_AFTER_EVENT:String(cursor)});exit=restarted.exit;if(exit===0){const second=JSON.parse(readFileSync('artifacts/windows-dlp-mvp-restart-native-proof.json','utf8'));if(first.pid===restarted.pid||proof.processId===second.restartedProcessId||devicesBefore!==1||store.db.prepare('SELECT COUNT(*) AS count FROM devices').get().count!==devicesBefore)throw Error('Native restart enrollment isolation failed');writeFileSync('artifacts/windows-dlp-mvp-process-restart-summary.json',JSON.stringify({actualTwoProcessRuns:true,sameProtectedUserData:true,sameManagementBackend:true,deviceCount:devicesBefore,enrollmentNotRepeated:true,firstProcessId:proof.processId,restartedProcessId:second.restartedProcessId,policyVersion:second.policyVersion,firstProof:'windows-dlp-mvp-first-process-native-proof.json',restartProof:'windows-dlp-mvp-restart-native-proof.json'},null,2));}}
|
||||
}catch(error){console.error('Native restart harness failed: '+error.name);exit=1;}finally{pendingAV?.();for(const socket of sockets)socket.destroy();await new Promise(r=>av.close(r));for(const server of [management,sink])server.closeAllConnections();await Promise.all([management,sink].map(server=>new Promise(r=>server.close(r))));store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});}process.exit(exit);
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
const {app,BrowserWindow,session,clipboard,ClipboardItem}=require('electron'),fs=require('node:fs'),path=require('node:path'),https=require('node:https'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto');
|
||||
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();const fixtures=JSON.parse(process.env.SAFETYNET_SEMANTIC_FIXTURES),origin=process.env.SAFETYNET_SMOKE_ORIGIN,base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN},localWitnesses=[];
|
||||
let actualDiagnostics;const diagnosticsModule=require('../apps/browser/diagnostics.cjs'),createDiagnostics=diagnosticsModule.createDiagnostics;diagnosticsModule.createDiagnostics=(...args)=>{const value=createDiagnostics(...args);actualDiagnostics=value;return value;};const inspection=require('../apps/browser/inspection.cjs'),startInspector=inspection.startInspector;inspection.startInspector=(...args)=>{const value=startInspector(...args),inspect=value.inspect.bind(value);value.inspect=async(bytes,policy)=>{const result=await inspect(bytes,policy);localWitnesses.push({model:result.model,decision:result.decision,semanticRule:result.ruleId==='semantic-workforce-fixture',coverageComplete:result.coverage?.complete===true,score:result.similarity?.find(s=>s.categoryId==='semantic-workforce-fixture')?.score});return result;};return value;};require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<400;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Native semantic fixture timeout');}
|
||||
app.whenReady().then(async()=>{let page,original=[],stage='startup',getFacts;const checks=[];try{for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);original.push(new ClipboardItem(data));}const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}callback(valid?0:-2);});getFacts=()=>new Promise((resolve,reject)=>{const request=https.get(origin+'/facts',{ca:fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem')),checkServerIdentity:(_host,cert)=>new X509Certificate(cert.raw).fingerprint256===fingerprint?undefined:Error('Synthetic certificate mismatch')},response=>{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(JSON.parse(body)));});request.on('error',reject);});const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true);return result.data;};stage='enrollment under reviewed semantic policy';const enrolled=await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});assert.equal(enrolled.policyVersion,Number(process.env.SAFETYNET_SEMANTIC_POLICY));page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');
|
||||
const key=async keyCode=>{win.focus();page.focus();page.sendInputEvent({type:'keyDown',keyCode,modifiers:['control']});page.sendInputEvent({type:'keyUp',keyCode,modifiers:['control']});await wait(500);await until(async()=>!(await ui('state')).clipboardPending);};const editor=async text=>page.executeJavaScript(`(()=>{const e=document.getElementById('editor');e.innerHTML='<p></p>';e.firstChild.textContent=${JSON.stringify(text)};e.focus();const r=document.createRange();r.selectNodeContents(e.firstChild);r.collapse(false);const s=getSelection();s.removeAllRanges();s.addRange(r);})()`),value=()=>page.executeJavaScript("document.getElementById('editor').innerText.trimEnd()");
|
||||
stage='native public AI editor paste';await editor('');const point=await page.executeJavaScript("(()=>{const r=document.getElementById('editor').getBoundingClientRect();return {x:Math.round(r.left+8),y:Math.round(r.top+10)}})()");page.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point});page.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point});await clipboard.writeText(fixtures.publicText);await key('V');assert.equal(await value(),fixtures.publicText);assert.equal(await page.executeJavaScript('window.submitPrompt()'),true);assert.equal((await getFacts()).publicPosts,1);checks.push('real-gemma-public-paste-submit');
|
||||
stage='native nonliteral paraphrase paste denied';await editor(fixtures.publicText);await clipboard.writeText(fixtures.paraphrase);await key('V');assert.equal(await value(),fixtures.publicText);assert.equal(await page.executeJavaScript('window.submitPrompt()'),true);assert.equal((await getFacts()).paraphrasePosts,0);assert.ok(localWitnesses.some(w=>w.model==='google/embeddinggemma-2'&&w.semanticRule&&w.decision==='block'&&w.score>=0.65));checks.push('real-gemma-paraphrase-paste-denied');
|
||||
stage='native confidential typed paraphrase denied';const before=(await getFacts()).posts;assert.equal(await page.executeJavaScript(`fetch('/receiver/prompt',{method:'POST',headers:{'content-type':'text/plain'},body:${JSON.stringify(fixtures.paraphrase)}}).then(r=>r.ok).catch(()=>false)`),false);assert.equal((await getFacts()).posts,before);checks.push('real-gemma-paraphrase-body-denied');
|
||||
stage='native public NTFS document allowed';const doc=await page.debugger.sendCommand('DOM.getDocument',{}),node=await page.debugger.sendCommand('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});const upload=async file=>{await page.debugger.sendCommand('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[file]});return page.executeJavaScript("(()=>{const body=new FormData();body.append('file',document.getElementById('file').files[0]);return fetch('/receiver/file',{method:'POST',body}).then(r=>r.ok).catch(()=>false)})()");};assert.equal(await upload(process.env.SAFETYNET_SMOKE_PUBLIC_FILE),true);assert.equal((await getFacts()).exactPublicFiles,1);stage='native paraphrase NTFS document denied';assert.equal(await upload(process.env.SAFETYNET_SMOKE_SECRET_FILE),false);let facts=await getFacts();assert.equal(facts.filePosts,1);assert.equal(facts.paraphrasePosts,0);assert.equal(facts.posts,3);checks.push('real-gemma-public-paraphrase-ntfs-document');
|
||||
stage='actual semantic OTEL and durable audit';await actualDiagnostics.flush();await wait(2000);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);for(const row of rows){assert.ok(!JSON.stringify(row).includes(fixtures.reference));assert.ok(!JSON.stringify(row).includes(fixtures.paraphrase));events.push(row);}if(rows.length<1000)break;const next=Number(response.headers.get('x-safetynet-next-cursor'));assert.ok(next>cursor);cursor=next;}assert.ok(events.some(e=>e.event.action==='teaching.published'));assert.ok(events.some(e=>e.event.action==='policy.published'));const traces=[];for(const action of ['clipboard.paste','clipboard.block','upload.authorized','upload.block']){const candidates=events.filter(e=>e.event.action===action&&e.trace?.id&&(action.endsWith('block')?e.rule?.id==='semantic-workforce-fixture':true));assert.ok(candidates.length,action+' semantic audit missing');let found;for(const event of candidates.reverse()){const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();if(action.startsWith('upload.')&&!trace.spans.some(s=>s.name==='browser.upload.lease'))continue;assert.equal(event.safetynet.policy_version,enrolled.policyVersion);assert.equal(event.host.id,enrolled.deviceId);assert.ok(!JSON.stringify(trace).includes(fixtures.reference));assert.ok(!JSON.stringify(trace).includes(fixtures.paraphrase));const semantic=trace.spans.find(s=>s.name==='inspector.semantic');assert.ok(semantic,'Real imported Gemma semantic stage absent');assert.ok(trace.spans.some(s=>s.name==='inspector.semantic.load'));const byId=new Map(trace.spans.map(s=>[s.spanId,s])),expected=action.startsWith('clipboard.')?'browser.clipboard':'browser.request';let current=semantic,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name===expected){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Semantic inference parent correlation missing');found={action,traceId:event.trace.id,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))};break;}assert.ok(found);traces.push(found);}const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.enabled,true);assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);facts=await getFacts();fs.writeFileSync(path.resolve('artifacts/windows-semantic-dlp-native-proof.json'),JSON.stringify({actualWindows:process.platform==='win32',actualElectron:true,localModelAutomaticBundledPaths:true,exactPinnedHTTPS:true,nonliteralParaphrase:true,policyVersion:enrolled.policyVersion,deviceId:enrolled.deviceId,checks,receiver:facts,localWitnesses,traces,diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Native local Gemma semantic paste/upload proof passed');if(original.length)await clipboard.write(original);else await clipboard.clear();require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){let facts;try{facts=await getFacts?.();await actualDiagnostics?.flush();}catch{}fs.writeFileSync(path.resolve('artifacts/windows-semantic-dlp-native-error.json'),JSON.stringify({stage,name:error.name,checks,facts,localWitnesses,diagnostics:actualDiagnostics?.status()},null,2));console.error('Native semantic MVP failed at '+stage+': '+error.name);try{if(original.length)await clipboard.write(original);else await clipboard.clear();}catch{}if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Real pinned local Gemma inference, reviewed teaching and a private HTTPS receiver.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createServer} from 'node:https';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,readFileSync,writeFileSync,rmSync,existsSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve,sep} from 'node:path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {inspect,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
const collector='http://127.0.0.1:4318';assert.equal((await fetch(collector+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(),temporary=mkdtempSync(join(tmpdir(),'safetynet-semantic-mvp-'));mkdirSync('artifacts',{recursive:true});assert.ok(resolve(temporary).startsWith(resolve(tmpdir())+sep)&&temporary.split(/[\\/]/).at(-1).startsWith('safetynet-semantic-mvp-'));
|
||||
const fixtures={reference:'Confidential corporate restructuring will close the Lisbon office next autumn and eliminate two hundred positions before the public announcement. Leadership is negotiating a six-million-euro acquisition of a rival. These undisclosed staffing reductions and acquisition plans are restricted internal company information.',paraphrase:'Private workforce planning calls for shutting the Portugal branch after summer and cutting roughly two hundred jobs. Executives are discussing a six-million-euro purchase of a competitor; the timetable and personnel reductions remain undisclosed.',publicText:'A simple tomato pasta recipe combines ripe tomatoes, olive oil, garlic and fresh basil. Boil the pasta in salted water and serve it warm.'};
|
||||
const runtime=resolve('.runtime/windows/semantic'),python=resolve('.runtime/windows/python/python.exe');assert.ok(existsSync(join(runtime,'models/model-manifest.json')));const publicFile=join(temporary,'public.txt'),secretFile=join(temporary,'paraphrase.txt');writeFileSync(publicFile,fixtures.publicText);writeFileSync(secretFile,fixtures.paraphrase);
|
||||
const facts={posts:0,publicPosts:0,paraphrasePosts:0,filePosts:0,exactPublicFiles:0};let inspector,management,sink,store,child,stage='startup';
|
||||
try{const inspectorToken=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'),code=randomBytes(32).toString('base64url');inspector=spawn(python,['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,EMBEDDING_MODEL_DIR:join(runtime,'models'),EMBEDDING_WORKER_PATH:join(runtime,'searchi-gemma-worker.exe'),INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}});inspector.stderr.on('data',()=>{});const inspectorPort=await new Promise((accept,reject)=>{let output='';const timer=setTimeout(()=>{inspector.kill();reject(Error('Private semantic inspector startup timed out'));},40000);inspector.once('error',()=>reject(Error('Private semantic inspector startup failed')));inspector.once('exit',()=>{clearTimeout(timer);reject(Error('Private semantic inspector exited'));});inspector.stdout.on('data',chunk=>{output+=chunk;if(output.includes('\n')){clearTimeout(timer);try{const startup=JSON.parse(output.split('\n')[0]);assert.equal(startup.containment.parserIsolation.active,true);assert.equal(startup.containment.parserIsolation.networkCapabilities,0);accept(startup.port);}catch{reject(Error('Private semantic inspector attestation invalid'));}}});});
|
||||
sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/facts'){res.setHeader('content-type','application/json');res.end(JSON.stringify(facts));return;}if(req.method==='POST'){let chunks=[],size=0;req.on('data',chunk=>{size+=chunk.length;if(size>1048576){req.destroy();return;}chunks.push(chunk);});req.on('end',()=>{facts.posts++;const bytes=Buffer.concat(chunks);if(bytes.includes(Buffer.from(fixtures.paraphrase)))facts.paraphrasePosts++;if(bytes.includes(Buffer.from(fixtures.publicText)))facts.publicPosts++;if(req.url==='/receiver/file'){facts.filePosts++;const boundary=/boundary=(?:"([^"]+)"|([^;]+))/.exec(req.headers['content-type']||'');if(boundary){const start=bytes.indexOf(Buffer.from('\r\n\r\n')),end=bytes.indexOf(Buffer.from('\r\n--'+(boundary[1]||boundary[2])),start+4);if(start>=0&&end>=0&&bytes.subarray(start+4,end).equals(Buffer.from(fixtures.publicText)))facts.exactPublicFiles++;}}bytes.fill(0);for(const chunk of chunks)chunk.fill(0);chunks=[];res.end('accepted');});return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Public local AI workspace</title><style>body{font:18px Arial;padding:25px}[contenteditable]{border:1px solid #999;width:85%;min-height:60px;margin:25px 0}</style><h1>Public AI workspace</h1><div id="editor" role="textbox" contenteditable="true"><p><br></p></div><input id="file" type="file"><script>window.submitPrompt=()=>fetch("/receiver/prompt",{method:"POST",headers:{"content-type":"text/plain"},body:document.getElementById("editor").innerText.trimEnd()}).then(r=>r.ok).catch(()=>false)</script>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));const origin=`https://127.0.0.1:${sink.address().port}`;
|
||||
store=openStore(join(temporary,'db'));const p=store.get('policy');p.browser.homeUrl=origin+'/workspace';p.browser.allowedHosts=['127.0.0.1'];p.agents.enabled=false;p.dlp.rules=[];p.security.yara.enabled=false;p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+1800000,'semantic-mvp-user');const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+1800000,JSON.stringify({sub:'independent-semantic-reviewer',role:'admin'}));const headers={authorization:'Bearer '+adminToken,'content-type':'application/json'};
|
||||
async function admin(route,body,method='POST',status=200){const result=await fetch(config.publicUrl+route,{method,headers,body:JSON.stringify(body),signal:AbortSignal.timeout(120000)});assert.equal(result.status,status,'Semantic admin operation refused');return result.json();}
|
||||
async function approve(review){const result=await fetch(config.publicUrl+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+reviewer,origin:config.publicUrl,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(result.status,200);}
|
||||
stage='reviewed document teaching';const teaching=await admin('/api/admin/teaching',{label:'Synthetic restricted workforce reference',filename:'synthetic-reference.txt',data:Buffer.from(fixtures.reference).toString('base64')});assert.equal(teaching.reference.registrationReady,true);assert.ok(teaching.reference.fragmentHashes.length>0);const publication=await admin(`/api/admin/teaching/${teaching.id}/publish`,{expectedVersion:p.version,approve:true},'POST',202);await approve(publication);const retained=JSON.parse(store.db.prepare('SELECT reference FROM teaching WHERE id=?').get(teaching.id).reference);assert.deepEqual(Object.keys(retained).sort(),['bytes','contentType','coverage','fragmentHashes','registrationReady','sha256']);assert.ok(!JSON.stringify(retained).includes(fixtures.reference));assert.ok(!JSON.stringify(retained).includes('Lisbon office'));assert.ok(retained.fragmentHashes.every(hash=>/^[a-f0-9]{64}$/.test(hash)));
|
||||
stage='real pinned local document embedding';const reference=await admin('/api/admin/semantic/document-reference',{filename:'synthetic-reference.txt',data:Buffer.from(fixtures.reference).toString('base64')});const model={model:'google/embeddinggemma-2',revision:'bfcd298762cc34d0357ece5ebdd31791a3a374d8',dimensions:768,profile:'document-v1',artifactFormat:'gguf',quantization:'Q8_0',runtimeRevision:'b11461',local:true};for(const [key,value]of Object.entries(model))assert.equal(reference[key],value,'Pinned local model contract changed');assert.equal(reference.coverage.complete,true);const semanticPolicy=store.get('policy');semanticPolicy.version++;semanticPolicy.dlp.semantic={enabled:true,model:reference.model,revision:reference.revision,dimensions:reference.dimensions,profile:reference.profile,artifactFormat:reference.artifactFormat,quantization:reference.quantization,runtimeRevision:reference.runtimeRevision,categories:[{id:'semantic-workforce-fixture',label:'Synthetic restricted workforce plan',threshold:0.65,prototype:reference.vector,prototypes:reference.vectors}]};assert.equal(inspect(Buffer.from(fixtures.paraphrase),semanticPolicy).decision,'allow','Paraphrase matches deterministic teaching; semantic proof invalid');const secretResult=await admin('/api/admin/policy/test',{text:fixtures.paraphrase,policy:semanticPolicy}),publicResult=await admin('/api/admin/policy/test',{text:fixtures.publicText,policy:semanticPolicy});assert.equal(secretResult.ruleId,'semantic-workforce-fixture');assert.equal(publicResult.decision,'allow');await approve(await admin('/api/admin/policy',semanticPolicy,'PUT',202));const signed=verifyPolicy(store.envelope(),store.publicKey());assert.equal(signed.dlp.semantic.revision,model.revision);assert.ok(!JSON.stringify(signed).includes(fixtures.reference));
|
||||
const teachingProof={independentlyReviewedTeaching:true,independentlyReviewedSemanticPolicy:true,fullDocumentNotRetained:true,retainedKeys:Object.keys(retained).sort(),retainedFragmentCount:retained.fragmentHashes.length,referenceCoverageComplete:true,deterministicParaphraseAllowed:true,semanticParaphraseBlocked:true,publicSemanticAllowed:true,policyVersion:signed.version,model,paraphraseSimilarity:secretResult.similarity?.[0]?.score,publicSimilarity:publicResult.similarity?.[0]?.score};writeFileSync('artifacts/windows-semantic-teaching-proof.json',JSON.stringify(teachingProof,null,2));
|
||||
stage='actual native semantic paste and upload';const env={...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ORIGIN:origin,SAFETYNET_SMOKE_PUBLIC_FILE:publicFile,SAFETYNET_SMOKE_SECRET_FILE:secretFile,SAFETYNET_SEMANTIC_FIXTURES:JSON.stringify(fixtures),SAFETYNET_SEMANTIC_POLICY:String(signed.version)};delete env.EMBEDDING_MODEL_DIR;delete env.EMBEDDING_WORKER_PATH;child=spawn(createRequire(import.meta.url)('electron'),['scripts/windows-semantic-dlp-smoke.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env});child.stdout.on('data',chunk=>process.stdout.write(chunk));child.stderr.on('data',chunk=>process.stderr.write(chunk));const timer=setTimeout(()=>child.kill(),240000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Native semantic MVP failed');assert.equal(facts.paraphrasePosts,0);assert.equal(facts.filePosts,1);assert.equal(facts.exactPublicFiles,1);console.log('Reviewed local Gemma semantic MVP proof passed');
|
||||
}catch(error){writeFileSync('artifacts/windows-semantic-harness-error.json',JSON.stringify({stage,name:error.name},null,2));console.error('Semantic MVP harness failed at '+stage+': '+error.name);process.exitCode=1;}finally{child?.kill();inspector?.kill();for(const server of [management,sink])if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}store?.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});}
|
||||
@@ -26,3 +26,5 @@ test('controlled credential submission and agent preparation start off and round
|
||||
});
|
||||
|
||||
test('visual export remains separate unchecked page-only opt-in in central and individual grants',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);assert.ok(scopes.includes('screenshot'));const tag=html.match(/<input[^>]*id="agent-scope-screenshot"[^>]*>/)[0];assert.equal(/\bchecked\b/.test(tag),false);for(const text of ['Export inspected foreground page viewport','does not authorize desktop, other windows or browser-interface capture','Sensitive-read permission does not bypass visual inspection'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('for(const scope of agentScopes)'));assert.ok(editor.includes('check.checked=grant.scopes.includes(scope)'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));assert.ok(editor.includes('never the desktop or browser interface'));});
|
||||
|
||||
test('attributed worker controls default off and profile editor cannot widen baseline',()=>{for(const id of ['agent-background-workers','agent-scope-network:background'])assert.equal(/\bchecked\b/.test(html.match(new RegExp(`<input[^>]*id="${id}"[^>]*>`))[0]),false);assert.ok(html.includes('not an RPC action'));assert.ok(html.includes('human/default/shared/legacy workers'));const select=html.match(/<select id="profile-agentBackgroundWorkers">([\s\S]*?)<\/select>/)[1];assert.ok(select.includes('value="inherit"'));assert.ok(select.includes('value="deny"'));assert.ok(!select.includes('value="allow"'));const editor=readFileSync(new URL('../apps/server/public/profile-admin.js',import.meta.url),'utf8');assert.ok(editor.includes("'agentBackgroundWorkers'"));});
|
||||
@@ -3,6 +3,15 @@ import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import audit from '../packages/core/agent-audit.cjs';
|
||||
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||
|
||||
test('worker audit retains immutable principal generation without inventing a foreground session',()=>{
|
||||
const facts=audit.agentAuditFacts({action:'network:background',outcome:'completed',principalId:'worker-agent',credentialGeneration:3});
|
||||
assert.equal(facts.scope,'network:background');assert.equal(facts.sessionId,undefined);
|
||||
assert.deepEqual(audit.agentFactsForEvent('agent.action',facts),facts);
|
||||
assert.equal(audit.agentFactsForEvent('agent.denied',facts),null);
|
||||
assert.throws(()=>audit.agentAuditFacts({...facts,credentialGeneration:undefined}));
|
||||
for(const key of ['body','cookies','authorization','initiator','url'])assert.throws(()=>audit.agentAuditFacts({...facts,[key]:'PRIVATE_WORKER_DATA'}));
|
||||
});
|
||||
test('agent audit binds every fixed action to its scope and rejects secret-bearing extras',()=>{
|
||||
for(const [action,scope] of Object.entries(audit.AGENT_SCOPES)){
|
||||
const outcome=action==='agent-request'?'denied':'completed';
|
||||
|
||||
@@ -147,6 +147,7 @@ test('bookmark mutation schema requires exact revision and rejects extra capabil
|
||||
assert.equal((await request('bookmarks:mutate',{...args,expectedRevision:-1})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,approve:true})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...args.bookmark,managed:true}})).status,400);
|
||||
// eslint-disable-next-line no-unused-vars -- The test deliberately removes the optional folder field from the request.
|
||||
const {folder,...withoutFolder}=args.bookmark;
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200);
|
||||
assert.equal(captured.bookmark.folder,'');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{createAgentSessionOwnership}=require('../apps/browser/agent-session-ownership.cjs');
|
||||
const owner={deviceId:'00000000-0000-4000-8000-000000000001',principalId:'agent-one',generation:1};
|
||||
function fixture(){const created=[],installed=[],disposed=[],quarantined=[],live=new Map();let background=false;const deps={createSession:async(partition,options)=>{const session={partition,options,cookies:new Map()};created.push(session);return session;},installGuards:async(session,who,guard)=>{installed.push({session,who,guard});},isLive:(who,options)=>live.get(who.principalId)!==false&&(!options.background||background),quarantineSession:s=>quarantined.push(s),disposeSession:async s=>disposed.push(s)};return {created,installed,disposed,quarantined,live,deps,allowBackground:()=>background=true,make:()=>createAgentSessionOwnership(deps)};}
|
||||
test('native partitions and cookies are isolated immutable owners and installed before use',async()=>{const f=fixture(),r=f.make(),a=await r.acquire(owner),b=await r.acquire({...owner,principalId:'agent-two'});assert.notEqual(a,b);assert.notEqual(a.partition,b.partition);assert.equal(a.partition.startsWith('persist:'),false);assert.deepEqual(a.options,{cache:false});a.cookies.set('session','A');assert.equal(b.cookies.has('session'),false);assert.equal(f.installed.length,2);assert.equal(Object.isFrozen(r.getOwner(a)),true);assert.deepEqual(r.getOwner(a),owner);assert.equal(await r.acquire(owner),a);await r.close();});
|
||||
test('guard installation must finish before native session is acquired',async()=>{const f=fixture();let finish;f.deps.installGuards=()=>new Promise(resolve=>finish=resolve);const r=f.make();let released=false;const promise=r.acquire(owner).then(s=>{released=true;return s;});await new Promise(resolve=>setImmediate(resolve));assert.equal(released,false);finish();await promise;assert.equal(released,true);await r.close();});
|
||||
test('worker background requests require separate live authority and copied guard options',async()=>{const f=fixture(),r=f.make(),session=await r.acquire(owner);assert.throws(()=>r.guard(session,{url:'https://example.com/',background:true}));f.allowBackground();const options={url:'https://example.com/',background:true},guard=r.guard(session,options);options.background=false;f.live.set(owner.principalId,false);assert.throws(guard.assertCurrent);await r.close();});
|
||||
test('revoked generation never reuses partition or cookie state, newer generation gets fresh native session',async()=>{const f=fixture(),r=f.make(),old=await r.acquire(owner),guard=r.guard(old);old.cookies.set('login','private');await r.revoke(owner);assert.throws(guard.assertCurrent);await assert.rejects(r.acquire(owner));const next=await r.acquire({...owner,generation:2});assert.notEqual(next.partition,old.partition);assert.equal(next.cookies.size,0);assert.deepEqual(f.quarantined,[old]);assert.ok(f.disposed.includes(old));await r.close();});
|
||||
test('revocation during guard install quarantines and never releases partially installed session',async()=>{const f=fixture();let finish;f.deps.installGuards=()=>new Promise(resolve=>finish=resolve);const r=f.make(),acquire=r.acquire(owner);await new Promise(resolve=>setImmediate(resolve));const revoke=r.revoke(owner);finish();await assert.rejects(acquire);await revoke;assert.equal(f.disposed.length,1);await assert.rejects(r.acquire(owner));await r.close();});
|
||||
test('native session object cannot be retagged or borrowed from another principal',async()=>{const f=fixture(),shared={};f.deps.createSession=async()=>shared;const r=f.make();await r.acquire(owner);await assert.rejects(r.acquire({...owner,principalId:'other'}));assert.deepEqual(r.getOwner(shared),owner);await r.close();});
|
||||
test('failed install, aborted acquisition and capacity preserve fail-closed ownership',async()=>{const f=fixture();f.deps.installGuards=async()=>{throw Error('Guards incomplete');};const r=f.make();await assert.rejects(r.acquire(owner));assert.equal(f.disposed.length,1);await assert.rejects(r.acquire(owner));await r.close();const g=fixture();g.deps.maxSessions=1;const s=g.make();await s.acquire(owner);await assert.rejects(s.acquire({...owner,principalId:'other'}));await assert.rejects(s.acquire({...owner,principalId:'third'},{signal:AbortSignal.abort()}));await s.close();});
|
||||
test('failed native cleanup retains capacity and disposal flag until explicit retry succeeds',async()=>{const f=fixture();f.deps.maxSessions=1;let failCleanup=true,calls=0;f.deps.disposeSession=async s=>{calls++;if(failCleanup)throw Error('Native cleanup failed');f.disposed.push(s);};const r=f.make(),native=await r.acquire(owner);await assert.rejects(r.revoke(owner));const rows=r.enumerateEntries();assert.equal(rows.length,1);assert.equal(rows[0].active,false);assert.equal(rows[0].disposed,false);assert.equal(rows[0].session,native);await assert.rejects(r.acquire({...owner,generation:2}));failCleanup=false;const result=await r.sweep();assert.equal(result[0].status,'fulfilled');assert.equal(r.enumerateEntries().length,0);assert.equal(calls,2);await r.acquire({...owner,generation:2});await r.close();});
|
||||
test('policy sweep invalidates live guards synchronously and readonly inventory never exposes mutable owner',async()=>{const f=fixture(),r=f.make(),native=await r.acquire(owner),guard=r.guard(native);const entries=r.enumerateEntries(),sessions=r.liveSessions();assert.equal(Object.isFrozen(entries),true);assert.equal(Object.isFrozen(entries[0]),true);assert.equal(Object.isFrozen(entries[0].owner),true);assert.equal(Object.isFrozen(sessions),true);assert.deepEqual(sessions,[native]);f.live.set(owner.principalId,false);const sweeping=r.sweep();assert.throws(guard.assertCurrent);assert.equal(r.liveSessions().length,0);await sweeping;assert.equal(r.enumerateEntries().length,0);await r.close();});
|
||||
test('network release guard refuses requests before guard installation completes',async()=>{const f=fixture();let deniedEarly=false;f.deps.installGuards=async(_session,_owner,guard)=>{assert.doesNotThrow(()=>guard({phase:'install'}));assert.throws(()=>guard({phase:'network'}));deniedEarly=true;};const r=f.make(),native=await r.acquire(owner);assert.equal(deniedEarly,true);r.guard(native).assertCurrent();await r.close();});
|
||||
test('repeated close retries failed cleanup without reopening revoked sessions',async()=>{const f=fixture();let failCleanup=true;f.deps.disposeSession=async()=>{if(failCleanup)throw Error('Failure');};const r=f.make();await r.acquire(owner);await r.close();assert.equal(r.enumerateEntries().length,1);assert.equal(r.enumerateEntries()[0].disposed,false);failCleanup=false;await r.close();assert.equal(r.enumerateEntries().length,0);await assert.rejects(r.acquire({...owner,generation:2}));});
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import {createServer} from 'node:http';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {runApprovedAI} from '../apps/server/ai-gateway.mjs';
|
||||
|
||||
for(const change of ['revoke','same-version-policy','output-revoke','client-abort'])test(`gateway suppresses release after ${change} during actual ICAP scan`,async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-ai-race-')),store=openStore(directory),deviceId=randomUUID();
|
||||
store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run(deviceId,'Fixture','win32','Fixture',randomUUID());
|
||||
let deliveries=0,scans=0;const sockets=new Set(),controller=new AbortController();
|
||||
const provider=createServer((req,res)=>{deliveries++;req.resume();res.end(JSON.stringify({choices:[{message:{content:'Public answer'}}]}));}).listen(0,'127.0.0.1');
|
||||
await new Promise(r=>provider.once('listening',r));
|
||||
const scanner=net.createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.once('close',()=>sockets.delete(socket));let bytes=Buffer.alloc(0),done=false;
|
||||
socket.on('data',chunk=>{bytes=Buffer.concat([bytes,chunk]);if(done||!bytes.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;
|
||||
if(bytes.subarray(0,7).toString()==='OPTIONS'){socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD\r\nAllow: 204\r\nISTag: "fixture"\r\nEncapsulated: null-body=0\r\n\r\n');return;}
|
||||
scans++;
|
||||
if(change==='client-abort')controller.abort();
|
||||
if(change==='revoke'||change==='output-revoke'&&scans===2)store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(deviceId);
|
||||
else if(change==='same-version-policy'){const p=store.get('policy');p.ai.enabled=false;store.put('policy',p);}
|
||||
socket.end('ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n');
|
||||
});
|
||||
}).listen(0,'127.0.0.1');await new Promise(r=>scanner.once('listening',r));
|
||||
t.after(async()=>{for(const socket of sockets)socket.destroy();provider.closeAllConnections();await Promise.all([provider,scanner].map(server=>new Promise(r=>server.close(r))));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
const p=store.get('policy');p.ai={...p.ai,enabled:true,trustMode:'local',model:'fixture'};p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${scanner.address().port}/scan`,timeoutMs:1000};store.put('policy',p);
|
||||
const policy=store.effectivePolicy(deviceId),config={aiBaseUrl:`http://127.0.0.1:${provider.address().port}`,aiModel:'fixture',aiEndpointId:'default'};
|
||||
await assert.rejects(runApprovedAI({store,config,deviceId,policy,prompt:'Public fixture prompt',signal:controller.signal}),error=>error.status===(change==='same-version-policy'?409:403)&&error.ruleId==='ai-authorization-changed');
|
||||
assert.equal(deliveries,change==='output-revoke'?1:0);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {join} from 'node:path';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {pruneAuditEvents,startAuditRetention} from '../apps/server/audit-retention.mjs';
|
||||
function fixture(t){const directory=mkdtempSync(join(tmpdir(),'safetynet-retention-')),store=openStore(directory);t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});return store;}
|
||||
const old='2020-01-01T00:00:00.000Z';
|
||||
test('retention preserves pending export, incident evidence and replay deduplication',t=>{
|
||||
const s=fixture(t);s.db.exec('CREATE TABLE IF NOT EXISTS incident_cases(id TEXT PRIMARY KEY,event_id INTEGER REFERENCES events(id))');
|
||||
for(let i=0;i<4;i++)s.event('device','dlp.block',{ruleId:'rule'});
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {BookmarkSchema,BookmarkItemsSchema,verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs';
|
||||
|
||||
@@ -59,3 +59,15 @@ test('clipboard completion metadata follows actual commit and native write, neve
|
||||
events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0);
|
||||
options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0);
|
||||
});
|
||||
|
||||
test('paste rereads external clipboard per gesture and never swaps approved bytes during asynchronous scan',async()=>{
|
||||
const frame={},wc={mainFrame:frame,focusedFrame:frame};let clipboard='First public clipboard',context={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc};
|
||||
const scanned=[],inserted=[],events=[];let waiting=deferred(),started=deferred();
|
||||
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:''}),validate:async()=>({completed:true}),commit:async args=>{inserted.push(args.text);return {completed:true};},discard:async()=>{},readClipboard:async()=>clipboard,writeClipboard:async()=>{},inspect:async bytes=>{scanned.push(bytes.toString());started.resolve();return waiting.promise;},audit:async(kind,ruleId,facts)=>events.push({kind,ruleId,facts})});
|
||||
const first=guard.perform('paste',wc);await started.promise;clipboard='Secret Roadmap changed by external app';waiting.resolve({decision:'allow'});assert.equal((await first).completed,true);assert.deepEqual(inserted,['First public clipboard']);
|
||||
waiting=deferred();started=deferred();const second=guard.perform('paste',wc);await started.promise;assert.equal(scanned.at(-1),clipboard);waiting.resolve({decision:'block',ruleId:'company-secret'});assert.equal((await second).completed,false);assert.deepEqual(inserted,['First public clipboard']);
|
||||
clipboard='Third public clipboard';waiting=deferred();started=deferred();const third=guard.perform('paste',wc);await started.promise;context={...context,policy:{...context.policy,version:context.policy.version+1}};waiting.resolve({decision:'allow'});assert.equal((await third).completed,false);assert.deepEqual(inserted,['First public clipboard']);
|
||||
assert.ok(events.some(e=>e.kind==='clipboard.paste'&&e.facts.action==='paste'&&e.facts.sessionId===context.tabId&&e.facts.outcome==='authorized'));
|
||||
assert.ok(events.some(e=>e.kind==='clipboard.block'&&e.ruleId==='company-secret'));
|
||||
assert.equal(JSON.stringify(events).includes('clipboard changed by external'),false);assert.equal(JSON.stringify(events).includes('Secret Roadmap'),false);assert.equal(JSON.stringify(events).includes('First public clipboard'),false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const {app,BrowserWindow}=require('electron');
|
||||
const assert=require('node:assert/strict');
|
||||
const {clipboardScript}=require('../apps/browser/clipboard-dom.cjs');
|
||||
const {createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs');
|
||||
app.disableHardwareAcceleration();app.enableSandbox();
|
||||
app.whenReady().then(async()=>{
|
||||
const {defaultPolicy}=await import('../packages/core/policy.mjs');const {inspectClipboard}=await import('../packages/core/clipboard.mjs');
|
||||
const win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}}),wc=win.webContents;
|
||||
await wc.loadURL('data:text/html,<div id="editor" contenteditable="true" role="textbox"><p>Public draft</p></div><input id="other">');
|
||||
const url=wc.getURL(),exec=code=>wc.executeJavaScriptInIsolatedWorld(999,[{code}],false);
|
||||
const focus=async()=>exec("(()=>{const e=document.getElementById('editor');e.focus();const r=document.createRange();r.selectNodeContents(e);r.collapse(false);const s=getSelection();s.removeAllRanges();s.addRange(r);})()");
|
||||
const dom=(mode,args)=>exec(clipboardScript(mode,{...args,url,limit:4096}));
|
||||
const policy=defaultPolicy();policy.dlp.rules=[{id:'secret',label:'Secret',kind:'literal',value:'Secret Roadmap',action:'block'}];let paste=' public <img src=x onerror=alert(1)>',writes=[];
|
||||
|
||||
// Hidden native contents can report no focused frame; exercise the DOM under
|
||||
// its real main frame, and use a context facade for native focus checks.
|
||||
const facade={mainFrame:wc.mainFrame,focusedFrame:wc.mainFrame};
|
||||
const args={url,token:'one',operation:'paste',limit:4096};
|
||||
await focus();await dom('stage',args);await dom('commit',{...args,text:paste});
|
||||
assert.equal(await exec("document.getElementById('editor').querySelector('img')===null"),true);
|
||||
assert.equal((await exec("document.getElementById('editor').textContent")).includes(paste),true);
|
||||
for(const change of ["document.getElementById('editor').append(document.createTextNode('changed'))","document.getElementById('other').focus()","getSelection().removeAllRanges()"]){await focus();await dom('stage',{...args,token:change});await exec(change);await assert.rejects(dom('commit',{...args,token:change,text:'should not insert'}));}
|
||||
await focus();await dom('stage',{...args,token:'sensitive'});await exec("document.getElementById('editor').setAttribute('data-sensitive','true')");await assert.rejects(dom('commit',{...args,token:'sensitive',text:'no'}));await exec("document.getElementById('editor').removeAttribute('data-sensitive')");
|
||||
// A real native DOM plus the production guard proves DLP runs before mutation.
|
||||
const guarded=createClipboardGuard({getContext:()=>({ready:true,policy,url,tabId:'editor',activation:1,navigationId:1,webContents:facade}),stage:args=>dom('stage',args),validate:args=>dom('validate',args),commit:args=>dom('commit',args),discard:args=>dom('discard',args),readClipboard:async()=>paste,writeClipboard:async text=>writes.push(text),inspect:async(bytes,p,operation)=>inspectClipboard(bytes,p,operation),audit:async()=>{}});
|
||||
paste='Secret Roadmap';await focus();const before=await exec("document.getElementById('editor').innerHTML");assert.equal((await guarded.perform('paste',facade)).completed,false);assert.equal(await exec("document.getElementById('editor').innerHTML"),before);
|
||||
paste='powershell -EncodedCommand SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==';assert.equal((await guarded.perform('paste',facade)).completed,false);assert.equal(await exec("document.getElementById('editor').innerHTML"),before);
|
||||
paste=' benign AI prompt';assert.equal((await guarded.perform('paste',facade)).completed,true);assert.ok((await exec("document.getElementById('editor').textContent")).endsWith(paste));
|
||||
await exec("(()=>{const e=document.getElementById('editor');e.textContent='Public editor selection';e.focus();const r=document.createRange();r.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(r);})()");
|
||||
assert.equal((await guarded.perform('copy',facade)).completed,true);assert.equal(writes.at(-1),'Public editor selection');
|
||||
assert.equal((await guarded.perform('cut',facade)).completed,true);assert.equal(writes.at(-1),'Public editor selection');assert.equal(await exec("document.getElementById('editor').textContent"),'');
|
||||
await exec("document.getElementById('editor').setAttribute('aria-label','API secret')");await assert.rejects(dom('stage',{...args,token:'sensitive-stage'}));
|
||||
console.log('CONTENTEDITABLE_CLIPBOARD_PASS');win.destroy();app.quit();
|
||||
}).catch(error=>{console.error(error.stack);app.exit(1);});
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user