diff --git a/.env.example b/.env.example index 50f6cc6..1d88004 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 3873f43..cd2f16c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs index cd8d5e0..81708ea 100644 --- a/apps/browser/agent-adapters.cjs +++ b/apps/browser/agent-adapters.cjs @@ -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; diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs index 031b4b3..6262c2c 100644 --- a/apps/browser/agent-control.cjs +++ b/apps/browser/agent-control.cjs @@ -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;} diff --git a/apps/browser/agent-grants.cjs b/apps/browser/agent-grants.cjs index 221b972..ee454fc 100644 --- a/apps/browser/agent-grants.cjs +++ b/apps/browser/agent-grants.cjs @@ -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}; diff --git a/apps/browser/agent-session-adapters.cjs b/apps/browser/agent-session-adapters.cjs index 69a521f..cd000e7 100644 --- a/apps/browser/agent-session-adapters.cjs +++ b/apps/browser/agent-session-adapters.cjs @@ -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)})};} diff --git a/apps/browser/agent-session-ownership.cjs b/apps/browser/agent-session-ownership.cjs new file mode 100644 index 0000000..4abaae7 --- /dev/null +++ b/apps/browser/agent-session-ownership.cjs @@ -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}; + + diff --git a/apps/browser/bookmarks.cjs b/apps/browser/bookmarks.cjs index bccd065..20a851f 100644 --- a/apps/browser/bookmarks.cjs +++ b/apps/browser/bookmarks.cjs @@ -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'); diff --git a/apps/browser/clipboard-dom.cjs b/apps/browser/clipboard-dom.cjs index 4b6af3b..bf690db 100644 --- a/apps/browser/clipboard-dom.cjs +++ b/apps/browser/clipboard-dom.cjs @@ -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}; diff --git a/apps/browser/credential-submission.cjs b/apps/browser/credential-submission.cjs index 28eacd8..b14d3dd 100644 --- a/apps/browser/credential-submission.cjs +++ b/apps/browser/credential-submission.cjs @@ -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;index131072)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);} diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 5558a50..d53a5d9 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -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)})]}); diff --git a/apps/browser/evidence-capture.cjs b/apps/browser/evidence-capture.cjs index fed70af..2488076 100644 --- a/apps/browser/evidence-capture.cjs +++ b/apps/browser/evidence-capture.cjs @@ -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; } diff --git a/apps/browser/evidence-delivery.cjs b/apps/browser/evidence-delivery.cjs index 716c1d2..b860945 100644 --- a/apps/browser/evidence-delivery.cjs +++ b/apps/browser/evidence-delivery.cjs @@ -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); diff --git a/apps/browser/header-inspection.cjs b/apps/browser/header-inspection.cjs index fc27f15..24edfe9 100644 --- a/apps/browser/header-inspection.cjs +++ b/apps/browser/header-inspection.cjs @@ -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'); diff --git a/apps/browser/inspect-text-representations.cjs b/apps/browser/inspect-text-representations.cjs new file mode 100644 index 0000000..30aeb92 --- /dev/null +++ b/apps/browser/inspect-text-representations.cjs @@ -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;indexinspectLocal(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}; diff --git a/apps/browser/inspection.cjs b/apps/browser/inspection.cjs index 29ffee3..4ae13b6 100644 --- a/apps/browser/inspection.cjs +++ b/apps/browser/inspection.cjs @@ -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);} }; diff --git a/apps/browser/library-organization.cjs b/apps/browser/library-organization.cjs index 8f9a73c..09cb69a 100644 --- a/apps/browser/library-organization.cjs +++ b/apps/browser/library-organization.cjs @@ -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();} diff --git a/apps/browser/local-dlp-relay.cjs b/apps/browser/local-dlp-relay.cjs new file mode 100644 index 0000000..f7591e9 --- /dev/null +++ b/apps/browser/local-dlp-relay.cjs @@ -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}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index c12603f..700f88f 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -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;indexcallback(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.time2000)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(); }); - diff --git a/apps/browser/proxy-credentials.cjs b/apps/browser/proxy-credentials.cjs new file mode 100644 index 0000000..6856abc --- /dev/null +++ b/apps/browser/proxy-credentials.cjs @@ -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}; diff --git a/apps/browser/session-snapshot.cjs b/apps/browser/session-snapshot.cjs index 2e27cd1..e77ffc4 100644 --- a/apps/browser/session-snapshot.cjs +++ b/apps/browser/session-snapshot.cjs @@ -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. diff --git a/apps/browser/startup-security.cjs b/apps/browser/startup-security.cjs index 49f71f2..741faef 100644 --- a/apps/browser/startup-security.cjs +++ b/apps/browser/startup-security.cjs @@ -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; diff --git a/apps/browser/transport-deny.cjs b/apps/browser/transport-deny.cjs new file mode 100644 index 0000000..6bb2341 --- /dev/null +++ b/apps/browser/transport-deny.cjs @@ -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');} diff --git a/apps/browser/upload-lease.cjs b/apps/browser/upload-lease.cjs index d708b1c..feade45 100644 --- a/apps/browser/upload-lease.cjs +++ b/apps/browser/upload-lease.cjs @@ -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}); diff --git a/apps/browser/vault-agent-controls.cjs b/apps/browser/vault-agent-controls.cjs index b5e839c..3e6a1ac 100644 --- a/apps/browser/vault-agent-controls.cjs +++ b/apps/browser/vault-agent-controls.cjs @@ -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); diff --git a/apps/server/ai-gateway.mjs b/apps/server/ai-gateway.mjs index fc84680..d42ba07 100644 --- a/apps/server/ai-gateway.mjs +++ b/apps/server/ai-gateway.mjs @@ -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();} }); } diff --git a/apps/server/app.mjs b/apps/server/app.mjs index 44f4cfa..3b20abd 100644 --- a/apps/server/app.mjs +++ b/apps/server/app.mjs @@ -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'); -} - - - - - - - - diff --git a/apps/server/company-news.mjs b/apps/server/company-news.mjs index c4ea7ab..15aebcb 100644 --- a/apps/server/company-news.mjs +++ b/apps/server/company-news.mjs @@ -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'); diff --git a/apps/server/evidence.mjs b/apps/server/evidence.mjs index 9ddcc90..84ec1cf 100644 --- a/apps/server/evidence.mjs +++ b/apps/server/evidence.mjs @@ -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}$/); diff --git a/apps/server/incidents.mjs b/apps/server/incidents.mjs index f61ba9d..a10cb3b 100644 --- a/apps/server/incidents.mjs +++ b/apps/server/incidents.mjs @@ -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('seqq.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));}); } diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js index a3d37b5..8c51571 100644 --- a/apps/server/public/admin.js +++ b/apps/server/public/admin.js @@ -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}}; diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js index 93a4c60..208f25c 100644 --- a/apps/server/public/agent-grants-admin.js +++ b/apps/server/public/agent-grants-admin.js @@ -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);} diff --git a/apps/server/public/controlled-login-admin.js b/apps/server/public/controlled-login-admin.js new file mode 100644 index 0000000..7bc577d --- /dev/null +++ b/apps/server/public/controlled-login-admin.js @@ -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); diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 3258333..2d2a103 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -9,13 +9,13 @@

CENTRAL CONFIGURATION

One policy. Every managed browser.

Devices refresh every 15 seconds. Changes and actor identity are recorded in the audit log.

-

IDENTITY AND FEATURE ACCESS

User classes and website access

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.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

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.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

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.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

+

IDENTITY AND FEATURE ACCESS

User classes and website access

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.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

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.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

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.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

COMPANY LIBRARY

Managed bookmarks

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.

AI library organization

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.

TitleHTTPS / HTTP URL (no query or fragment)FolderActions

Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.

Device personal bookmarks

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.

TEACH YOUR BROWSER

Define what must stay inside

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.

Local semantic reference protection

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.

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.

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.

-

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

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.

Visual export is a separate opt-in capability for the active managed webpage viewport only. It does not authorize desktop, other windows or browser-interface capture. Required content inspection must succeed before pixels are returned. Grant it in both the central baseline and the individual identity; remove the scope or disable agents and publish to revoke access. Sensitive-read permission does not bypass visual inspection. Availability is limited to the implemented and tested export path.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

-

Governed document teaching

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.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Controlled credential submission

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.

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.

Publish signed policy to apply these controls.

Visual impersonation protection

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.

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.

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.

Managed clipboard and ClickFix protection

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.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess
+

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

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.

Disabled by default. Development support is limited to immutable agent-owned nonpersistent browser partitions bound to device, principal and credential generation. It additionally requires network:background in both baseline and individual grant, allowed destinations and current policy. Profiles may deny it and cannot expand permission. This does not enable human/default/shared/legacy workers or unattributed requests. Owner-registry migration and native verification are required; never infer ownership from the active tab.

Visual export is a separate opt-in capability for the active managed webpage viewport only. It does not authorize desktop, other windows or browser-interface capture. Required content inspection must succeed before pixels are returned. Grant it in both the central baseline and the individual identity; remove the scope or disable agents and publish to revoke access. Sensitive-read permission does not bypass visual inspection. Availability is limited to the implemented and tested export path.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

+

Governed document teaching

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.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Controlled credential submission

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.

Advanced login rule JSON

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.

Publish signed policy to apply these controls.

Visual impersonation protection

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.

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.

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.

Managed clipboard and ClickFix protection

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.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess

ADMINISTRATOR ACCESS

Active management sessions

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.

Session inventory requires the administrator role.

IdentityRoleExpiresAction

SECURITY OPERATIONS

Incident triage

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.

CreatedSecurity eventStatusSeverityAssigneeReview

AUDIT LIFECYCLE

Database event retention

Loading deployment configuration.

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.

SECURITY MONITORING

Recent audit events

TimeEventDeviceMetadata

This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.

-

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

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.

Download inspection

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.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

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.

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.

OBSERVABILITY

Browser OpenTelemetry

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.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

+

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

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.

Download inspection

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.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

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.

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.

OBSERVABILITY

Browser OpenTelemetry

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.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

diff --git a/apps/server/public/profile-admin.js b/apps/server/public/profile-admin.js index e86a76c..70e9495 100644 --- a/apps/server/public/profile-admin.js +++ b/apps/server/public/profile-admin.js @@ -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';} diff --git a/docs/controlled-credential-policy.md b/docs/controlled-credential-policy.md index b0149a8..f7fecb1 100644 --- a/docs/controlled-credential-policy.md +++ b/docs/controlled-credential-policy.md @@ -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 { diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md new file mode 100644 index 0000000..2f1ff06 --- /dev/null +++ b/docs/dependency-audit.md @@ -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 → sprintf-js@1.1.3`. + +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/fuses@2.1.3`; 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. diff --git a/docs/electron-best-practices.md b/docs/electron-best-practices.md new file mode 100644 index 0000000..f69134b --- /dev/null +++ b/docs/electron-best-practices.md @@ -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. diff --git a/docs/electron-security-review.md b/docs/electron-security-review.md new file mode 100644 index 0000000..8bac1d9 --- /dev/null +++ b/docs/electron-security-review.md @@ -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. diff --git a/docs/local-dlp-relay.md b/docs/local-dlp-relay.md new file mode 100644 index 0000000..639e25e --- /dev/null +++ b/docs/local-dlp-relay.md @@ -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. diff --git a/docs/native-runtime/README.md b/docs/native-runtime/README.md new file mode 100644 index 0000000..342f067 --- /dev/null +++ b/docs/native-runtime/README.md @@ -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 ` 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. diff --git a/docs/native-runtime/chromium-152-webrtc-binder-backport.patch b/docs/native-runtime/chromium-152-webrtc-binder-backport.patch new file mode 100644 index 0000000..0059654 --- /dev/null +++ b/docs/native-runtime/chromium-152-webrtc-binder-backport.patch @@ -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 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(frame->GetProcess()) + ->BindP2PSocketManager( + frame->GetIsolationInfoForSubresources().network_anonymization_key(), +@@ -991,26 +1005,9 @@ void PopulateBinderMapWithContext( + map->Add( + &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( ++ map->Add( + &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(&BindSocketManager); +- } ++ map->Add(&BindSocketManager); + #endif // BUILDFLAG(IS_P2P_ENABLED) + + map->Add( +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& 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 ++#include ++#include ++ ++#include "mojo/public/cpp/bindings/self_owned_receiver.h" ++ ++namespace content { ++ ++// static ++void NoOpMdnsResponder::Create( ++ mojo::PendingReceiver receiver) { ++ mojo::MakeSelfOwnedReceiver(std::make_unique(), ++ 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 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 ++ ++#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 responder_; ++}; ++ ++// CreateNameForAddress() must always run its callback, without creating a name ++// or scheduling an announcement. ++TEST_F(NoOpMdnsResponderTest, CreateNameForAddressFails) { ++ base::test::TestFuture 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 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 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 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 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 ++#include ++ ++#include "mojo/public/cpp/bindings/self_owned_receiver.h" ++#include "net/base/ip_address.h" ++ ++namespace content { ++ ++// static ++void NoOpP2PSocketManager::Create( ++ mojo::PendingReceiver receiver) { ++ mojo::MakeSelfOwnedReceiver(std::make_unique(), ++ std::move(receiver)); ++} ++ ++NoOpP2PSocketManager::NoOpP2PSocketManager() = default; ++NoOpP2PSocketManager::~NoOpP2PSocketManager() = default; ++ ++void NoOpP2PSocketManager::StartNetworkNotifications( ++ mojo::PendingRemote 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 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& devtools_token, ++ mojo::PendingRemote client, ++ mojo::PendingReceiver 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 ++#include ++ ++#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 receiver); ++ ++ NoOpP2PSocketManager(); ++ NoOpP2PSocketManager(const NoOpP2PSocketManager&) = delete; ++ NoOpP2PSocketManager& operator=(const NoOpP2PSocketManager&) = delete; ++ ~NoOpP2PSocketManager() override; ++ ++ // network::mojom::P2PSocketManager: ++ void StartNetworkNotifications( ++ mojo::PendingRemote client) ++ override; ++ void GetHostAddress(const std::string& host_name, ++ std::optional 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& devtools_token, ++ mojo::PendingRemote client, ++ mojo::PendingReceiver 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_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 ++#include ++ ++#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 ++ 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, ++ net::IPAddress, ++ net::IPAddress>& ++ network_list_future() { ++ return network_list_future_; ++ } ++ ++ // network::mojom::P2PNetworkNotificationClient: ++ void NetworkListChanged( ++ const std::vector& 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 receiver_{this}; ++ base::test::TestFuture, ++ 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 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&> 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&> 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 client_receiver; ++ mojo::PendingRemote client = ++ client_receiver.InitWithNewPipeAndPassRemote(); ++ ++ mojo::Remote socket; ++ base::test::TestFuture 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 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 = '

Hello World

') { ++ 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 = '

Hello World

') { ++ 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 diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index 9764882..e29b50b 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -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, diff --git a/docs/windows-dlp-mvp.md b/docs/windows-dlp-mvp.md new file mode 100644 index 0000000..bcf04ba --- /dev/null +++ b/docs/windows-dlp-mvp.md @@ -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. diff --git a/docs/worker-ownership.md b/docs/worker-ownership.md index 2a3804e..384324b 100644 --- a/docs/worker-ownership.md +++ b/docs/worker-ownership.md @@ -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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..18336e4 --- /dev/null +++ b/eslint.config.mjs @@ -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'}} +]; diff --git a/package-lock.json b/package-lock.json index 7a5c72d..48028e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,11 +25,76 @@ "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" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@electron-internal/extract-zip": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", @@ -90,34 +155,16 @@ } }, "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-2.1.3.tgz", + "integrity": "sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==", "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, "bin": { "electron-fuses": "dist/bin.js" - } - }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=22.12.0" } }, "node_modules/@electron/get": { @@ -334,6 +381,134 @@ "node": ">=14.14" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.6", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.6.tgz", @@ -365,6 +540,72 @@ "node": ">=6" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -388,6 +629,13 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1127,6 +1375,20 @@ "@types/ms": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -1144,6 +1406,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -1213,6 +1482,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1321,6 +1613,60 @@ "electron-builder-squirrel-windows": "26.15.3" } }, + "node_modules/app-builder-lib/node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/fuses/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/app-builder-lib/node_modules/@electron/get": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", @@ -1640,6 +1986,20 @@ "node": ">=6.0.0" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -1669,6 +2029,16 @@ "node": ">=8" } }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1983,6 +2353,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -2450,7 +2827,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -2458,6 +2834,185 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2524,6 +3079,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -2559,6 +3128,16 @@ } } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -2620,6 +3199,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -2793,6 +3408,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2843,6 +3471,19 @@ "node": ">=10.0" } }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -2958,6 +3599,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2970,6 +3624,13 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -3068,6 +3729,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-in-the-middle": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", @@ -3082,6 +3753,16 @@ "node": ">=18" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3109,6 +3790,16 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3118,6 +3809,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3228,6 +3932,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -3279,6 +3990,36 @@ "dev": true, "license": "MIT" }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3499,6 +4240,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -3689,6 +4437,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -3715,6 +4481,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3724,6 +4506,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -3877,6 +4669,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -3981,6 +4783,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -4001,6 +4813,26 @@ "node": ">=16.0.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -4699,6 +5531,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -4796,6 +5641,16 @@ "node": ">=14.14" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -4849,6 +5704,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 242722c..1433b83 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/core/agent-audit.cjs b/packages/core/agent-audit.cjs index 8ec5ab4..d7d1339 100644 --- a/packages/core/agent-audit.cjs +++ b/packages/core/agent-audit.cjs @@ -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'}); diff --git a/packages/core/bookmarks.mjs b/packages/core/bookmarks.mjs index 04d6261..67a3195 100644 --- a/packages/core/bookmarks.mjs +++ b/packages/core/bookmarks.mjs @@ -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({}); diff --git a/packages/core/credential-policy.mjs b/packages/core/credential-policy.mjs index 984114b..1850f94 100644 --- a/packages/core/credential-policy.mjs +++ b/packages/core/credential-policy.mjs @@ -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'); diff --git a/packages/core/file-transfer.cjs b/packages/core/file-transfer.cjs index b3e20c5..511c058 100644 --- a/packages/core/file-transfer.cjs +++ b/packages/core/file-transfer.cjs @@ -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'; } diff --git a/packages/core/library.mjs b/packages/core/library.mjs index f9d6ea9..6033cb2 100644 --- a/packages/core/library.mjs +++ b/packages/core/library.mjs @@ -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');} diff --git a/packages/core/policy-groups.mjs b/packages/core/policy-groups.mjs index 1b31e09..7b83f9a 100644 --- a/packages/core/policy-groups.mjs +++ b/packages/core/policy-groups.mjs @@ -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() diff --git a/packages/core/policy.mjs b/packages/core/policy.mjs index 95045a3..777d0e7 100644 --- a/packages/core/policy.mjs +++ b/packages/core/policy.mjs @@ -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'}; diff --git a/packages/core/security.mjs b/packages/core/security.mjs index 1b0d9ea..1ec46b9 100644 --- a/packages/core/security.mjs +++ b/packages/core/security.mjs @@ -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(); diff --git a/packages/core/text-variants.mjs b/packages/core/text-variants.mjs new file mode 100644 index 0000000..6736e99 --- /dev/null +++ b/packages/core/text-variants.mjs @@ -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;i4096)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); +} diff --git a/packages/core/user-profiles.mjs b/packages/core/user-profiles.mjs index 98d4464..240ad05 100644 --- a/packages/core/user-profiles.mjs +++ b/packages/core/user-profiles.mjs @@ -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; diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs index 173c9d9..a414e34 100644 --- a/scripts/agent-grants-history-admin-smoke.cjs +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -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); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs index 7004f4b..11f296e 100644 --- a/scripts/agent-grants-history-admin-smoke.mjs +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -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});} diff --git a/scripts/agent-visual-export-smoke.mjs b/scripts/agent-visual-export-smoke.mjs index 6ce8ae5..3a94010 100644 --- a/scripts/agent-visual-export-smoke.mjs +++ b/scripts/agent-visual-export-smoke.mjs @@ -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='Public visual fixture';if(req.url==='/canvas')res.end(base+'');else if(req.url==='/sensitive')res.end(base+'

Public form

');else if(req.url==='/closed-shadow')res.end(base+`
`);else if(req.url==='/open-shadow')res.end(base+`
`);else if(req.url==='/frames')res.end(base+'

Public parent

');else res.end(base+'

Public approved visual page

Research and documentation workspace.

');}).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='Public visual fixture';if(req.url==='/canvas')res.end(base+'');else if(req.url==='/sensitive')res.end(base+'

Public form

');else if(req.url==='/closed-shadow')res.end(base+`
`);else if(req.url==='/open-shadow')res.end(base+`
`);else if(req.url==='/frames')res.end(base+'

Public parent

');else res.end(base+'

Public approved visual page

Research and documentation workspace.

');}).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}; diff --git a/scripts/agent-worker-ownership-smoke.cjs b/scripts/agent-worker-ownership-smoke.cjs new file mode 100644 index 0000000..3fd8e74 --- /dev/null +++ b/scripts/agent-worker-ownership-smoke.cjs @@ -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);}}); diff --git a/scripts/agent-worker-ownership-smoke.mjs b/scripts/agent-worker-ownership-smoke.mjs new file mode 100644 index 0000000..0ef209c --- /dev/null +++ b/scripts/agent-worker-ownership-smoke.mjs @@ -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('Public worker fixture

Public worker ownership fixture

');}).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); diff --git a/scripts/bookmarks-admin-smoke.mjs b/scripts/bookmarks-admin-smoke.mjs index 5a62001..fca7747 100644 --- a/scripts/bookmarks-admin-smoke.mjs +++ b/scripts/bookmarks-admin-smoke.mjs @@ -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}); } diff --git a/scripts/controlled-login-smoke.cjs b/scripts/controlled-login-smoke.cjs index bdc07ec..da5b489 100644 --- a/scripts/controlled-login-smoke.cjs +++ b/scripts/controlled-login-smoke.cjs @@ -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); diff --git a/scripts/devtools-profile-smoke.mjs b/scripts/devtools-profile-smoke.mjs index 97fdce9..1c1974d 100644 --- a/scripts/devtools-profile-smoke.mjs +++ b/scripts/devtools-profile-smoke.mjs @@ -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});} diff --git a/scripts/enterprise-admin-smoke.mjs b/scripts/enterprise-admin-smoke.mjs index 9a5e9bf..571a676 100644 --- a/scripts/enterprise-admin-smoke.mjs +++ b/scripts/enterprise-admin-smoke.mjs @@ -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}); } diff --git a/scripts/fullmain-wt-native.cjs b/scripts/fullmain-wt-native.cjs new file mode 100644 index 0000000..f19cac6 --- /dev/null +++ b/scripts/fullmain-wt-native.cjs @@ -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);}}); diff --git a/scripts/fullmain-wt-proof.mjs b/scripts/fullmain-wt-proof.mjs new file mode 100644 index 0000000..cb5d22a --- /dev/null +++ b/scripts/fullmain-wt-proof.mjs @@ -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('Native transport fixture

Public native transport workspace

');}).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});} diff --git a/scripts/groups-admin-smoke.mjs b/scripts/groups-admin-smoke.mjs index 239f610..5a5dc40 100644 --- a/scripts/groups-admin-smoke.mjs +++ b/scripts/groups-admin-smoke.mjs @@ -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}); } diff --git a/scripts/incident-admin-smoke.mjs b/scripts/incident-admin-smoke.mjs index 1b6fd30..ba8583f 100644 --- a/scripts/incident-admin-smoke.mjs +++ b/scripts/incident-admin-smoke.mjs @@ -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}); } diff --git a/scripts/independent-review-admin-smoke.mjs b/scripts/independent-review-admin-smoke.mjs index 372345d..8bee5b7 100644 --- a/scripts/independent-review-admin-smoke.mjs +++ b/scripts/independent-review-admin-smoke.mjs @@ -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}); } diff --git a/scripts/native-tabs-smoke.cjs b/scripts/native-tabs-smoke.cjs index fa1fcb7..a338ce5 100644 --- a/scripts/native-tabs-smoke.cjs +++ b/scripts/native-tabs-smoke.cjs @@ -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');} diff --git a/scripts/native-tabs-smoke.mjs b/scripts/native-tabs-smoke.mjs index 31d1174..17388c5 100644 --- a/scripts/native-tabs-smoke.mjs +++ b/scripts/native-tabs-smoke.mjs @@ -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});} diff --git a/scripts/package-fuses.mjs b/scripts/package-fuses.mjs new file mode 100644 index 0000000..e813b01 --- /dev/null +++ b/scripts/package-fuses.mjs @@ -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; +} diff --git a/scripts/package.mjs b/scripts/package.mjs index 458f204..2b8412f 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -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); diff --git a/scripts/policy-history-admin-smoke.mjs b/scripts/policy-history-admin-smoke.mjs index 5e6596c..918c88e 100644 --- a/scripts/policy-history-admin-smoke.mjs +++ b/scripts/policy-history-admin-smoke.mjs @@ -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}); } diff --git a/scripts/profile-browser-modules.mjs b/scripts/profile-browser-modules.mjs new file mode 100644 index 0000000..472b7a0 --- /dev/null +++ b/scripts/profile-browser-modules.mjs @@ -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})); +} diff --git a/scripts/profiles-admin-smoke.mjs b/scripts/profiles-admin-smoke.mjs index 1d1bef6..6dfb02f 100644 --- a/scripts/profiles-admin-smoke.mjs +++ b/scripts/profiles-admin-smoke.mjs @@ -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}); } diff --git a/scripts/semantic-browser-smoke.cjs b/scripts/semantic-browser-smoke.cjs index 5decdcb..8875327 100644 --- a/scripts/semantic-browser-smoke.cjs +++ b/scripts/semantic-browser-smoke.cjs @@ -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(); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index a6eaa9f..216dabf 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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); diff --git a/scripts/verify-package-fuses.mjs b/scripts/verify-package-fuses.mjs new file mode 100644 index 0000000..954f65f --- /dev/null +++ b/scripts/verify-package-fuses.mjs @@ -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&&indexsetTimeout(()=>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);}); diff --git a/scripts/webtransport-native-proof.mjs b/scripts/webtransport-native-proof.mjs new file mode 100644 index 0000000..8d61980 --- /dev/null +++ b/scripts/webtransport-native-proof.mjs @@ -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('

Local WebTransport receiver proof

');}).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();} diff --git a/scripts/webtransport-receiver.py b/scripts/webtransport-receiver.py new file mode 100644 index 0000000..002e0ec --- /dev/null +++ b/scripts/webtransport-receiver.py @@ -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()) diff --git a/scripts/windows-dlp-mvp-restart-smoke.cjs b/scripts/windows-dlp-mvp-restart-smoke.cjs new file mode 100644 index 0000000..af77a23 --- /dev/null +++ b/scripts/windows-dlp-mvp-restart-smoke.cjs @@ -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='

';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);}}); diff --git a/scripts/windows-dlp-mvp-smoke.cjs b/scripts/windows-dlp-mvp-smoke.cjs new file mode 100644 index 0000000..fcf8f57 --- /dev/null +++ b/scripts/windows-dlp-mvp-smoke.cjs @@ -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='


';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(['

Public HTML-source prompt

'],{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(['

Public decoy

'],{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);}}); diff --git a/scripts/windows-dlp-mvp-smoke.mjs b/scripts/windows-dlp-mvp-smoke.mjs new file mode 100644 index 0000000..27e3d52 --- /dev/null +++ b/scripts/windows-dlp-mvp-smoke.mjs @@ -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=`Public AI workspace

Public AI prompt workspace


`;
+// 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);
diff --git a/scripts/windows-semantic-dlp-smoke.cjs b/scripts/windows-semantic-dlp-smoke.cjs
new file mode 100644
index 0000000..b7beb95
--- /dev/null
+++ b/scripts/windows-semantic-dlp-smoke.cjs
@@ -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='

';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);}}); diff --git a/scripts/windows-semantic-dlp-smoke.mjs b/scripts/windows-semantic-dlp-smoke.mjs new file mode 100644 index 0000000..043beb9 --- /dev/null +++ b/scripts/windows-semantic-dlp-smoke.mjs @@ -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('Public local AI workspace

Public AI workspace


');}).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});} diff --git a/tests/admin-controls-ui.test.mjs b/tests/admin-controls-ui.test.mjs index 4bcb7ee..eb47b2e 100644 --- a/tests/admin-controls-ui.test.mjs +++ b/tests/admin-controls-ui.test.mjs @@ -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(/]*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(`]*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(/'); + 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 ',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);}); diff --git a/tests/contenteditable-clipboard.test.mjs b/tests/contenteditable-clipboard.test.mjs new file mode 100644 index 0000000..4b26e1d --- /dev/null +++ b/tests/contenteditable-clipboard.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +test('Chromium contenteditable plain paste blocks secrets, HTML execution and stale editor snapshots', {timeout:25000},async()=>{ + const require=createRequire(import.meta.url),env={...process.env};delete env.ELECTRON_RUN_AS_NODE; + const child=spawn(require('electron'),[fileURLToPath(new URL('./contenteditable-clipboard.fixture.cjs',import.meta.url))],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});let output=''; + child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b); + const timer=setTimeout(()=>child.kill(),20000);const code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);});clearTimeout(timer); + assert.equal(code,0,output);assert.match(output,/CONTENTEDITABLE_CLIPBOARD_PASS/); +}); diff --git a/tests/controlled-login-admin.test.mjs b/tests/controlled-login-admin.test.mjs new file mode 100644 index 0000000..f296e28 --- /dev/null +++ b/tests/controlled-login-admin.test.mjs @@ -0,0 +1,6 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {ControlledSubmissionSchema} from '../packages/core/credential-policy.mjs';const {validateCredentialRuleDraft}=createRequire(import.meta.url)('../apps/server/public/controlled-login-admin.js');const row=()=>({id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'}); +test('typed login editor roundtrips exact canonical metadata accepted by central schema',()=>{const value=[row(),{...row(),id:'other',actionUrl:'https://other.example/post',usernameField:'user[email]'}];const result=validateCredentialRuleDraft(value);assert.deepEqual(ControlledSubmissionSchema.parse({enabled:true,forms:result}).forms,value);assert.notEqual(result[0],value[0]);}); +test('typed login validation rejects ambiguous destinations and secret-bearing extras without echoing values',()=>{for(const rules of [[{...row(),password:'PRIVATE_SECRET'}],[{...row(),actionUrl:'https://u:PRIVATE_SECRET@login.example/post'}],[{...row(),actionUrl:'http://login.example/'}],[{...row(),actionUrl:'https://login.example/post?code=PRIVATE_SECRET'}],[row(),{...row(),id:'other'}],[{...row(),usernameField:'password'}],Array.from({length:33},row)]){assert.throws(()=>validateCredentialRuleDraft(rules),error=>!error.message.includes('PRIVATE_SECRET'));assert.throws(()=>ControlledSubmissionSchema.parse({forms:rules}));}}); + +import vm from 'node:vm';import {readFileSync} from 'node:fs'; +test('typed rows preserve reviewed targets, enforce auditor read-only and edit draft through actual field events',()=>{class Element{constructor(){this.children=[];this.listeners={};this.value='';this.checked=false;this.disabled=false;this.dataset={};}append(...nodes){this.children.push(...nodes);}replaceChildren(...nodes){this.children=nodes;}addEventListener(type,fn){this.listeners[type]=fn;}}const nodes=new Map(),get=id=>{if(!nodes.has(id))nodes.set(id,new Element());return nodes.get(id);};const original={passwordManager:{enabled:true,controlledSubmission:{enabled:true,forms:[row()]}}};let revision=0;const ctx=vm.createContext({document:{createElement:()=>new Element()},$:get,URL,structuredClone,fill(p){get('vault-submit-forms').value=JSON.stringify(p.passwordManager.controlledSubmission.forms);},draft:()=>({passwordManager:{...original.passwordManager,controlledSubmission:{enabled:get('vault-controlled-submit').checked,forms:JSON.parse(get('vault-submit-forms').value)}}}),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null,updateDraftStatus(){revision++;}});vm.runInContext(readFileSync(new URL('../apps/server/public/controlled-login-admin.js',import.meta.url),'utf8'),ctx);ctx.fill(original);ctx.applyOperatorPermissions({permissions:['policy.read']});assert.equal(get('vault-rule-add').disabled,true);let box=get('vault-rule-editor').children[0];assert.equal(box.children[1].children.every(label=>label.children[0].disabled),true);ctx.applyOperatorPermissions({permissions:['policy.prepare']});box=get('vault-rule-editor').children[0];const action=box.children[1].children[1].children[0];action.value='https://login.example/new';action.listeners.input();assert.equal(JSON.parse(get('vault-submit-forms').value)[0].actionUrl,'https://login.example/new');assert.equal(original.passwordManager.controlledSubmission.forms[0].actionUrl,row().actionUrl);assert.ok(revision>0);get('vault-submit-forms').value='[{"unknown":"PRIVATE_STAGED"}]';get('vault-submit-forms').listeners.change();assert.equal(get('vault-rule-add').disabled,true);action.value='https://login.example/discard';action.listeners.input();assert.equal(get('vault-submit-forms').value,'[{"unknown":"PRIVATE_STAGED"}]');get('vault-submit-forms').value=JSON.stringify([row()]);get('vault-submit-forms').listeners.change();box=get('vault-rule-editor').children[0];get('vault-controlled-submit').checked=true;assert.equal(ctx.draft().passwordManager.controlledSubmission.forms[0].id,'login');box.children[2].listeners.click();assert.throws(()=>ctx.draft(),/at least one/);}); diff --git a/tests/enterprise-gateway.test.mjs b/tests/enterprise-gateway.test.mjs index 945fb50..684d537 100644 --- a/tests/enterprise-gateway.test.mjs +++ b/tests/enterprise-gateway.test.mjs @@ -22,6 +22,7 @@ test('central ICAP capability probe sends no content; gateway cannot bypass exte assert.equal((await api('/api/admin/integrations/icap/probe',policy.enterprise.icap,device.token)).status,401); const probe=await api('/api/admin/integrations/icap/probe',policy.enterprise.icap);assert.equal(probe.status,200);assert.equal(probe.body.ready,true);assert.equal(probe.body.contentSent,false);assert.equal(scans,0); const blocked=await api('/api/device/ai',{prompt:marker},device.token);assert.equal(blocked.status,403);assert.equal(upstreamRequests,0); + const encoded=await api('/api/device/ai',{prompt:Buffer.from(marker).toString('base64')},device.token);assert.equal(encoded.status,403);assert.equal(upstreamRequests,0,'Decoded blocked content must never reach the provider'); const allowed=await api('/api/device/ai',{prompt:'Public benign prompt'},device.token);assert.equal(allowed.status,200);assert.equal(allowed.body.answer,'Public safe answer');assert.equal(upstreamRequests,1); infectAnswer=true;const response=await api('/api/device/ai',{prompt:'Public second prompt'},device.token);assert.equal(response.status,403);assert.equal(Object.hasOwn(response.body,'answer'),false); const events=store.db.prepare('SELECT metadata FROM events').all();assert.equal(JSON.stringify(events).includes(marker),false);assert.equal(JSON.stringify(events).includes('Public benign prompt'),false);assert.ok(scans>=5); diff --git a/tests/evidence-key.test.mjs b/tests/evidence-key.test.mjs index 8307ca9..7b0f0ea 100644 --- a/tests/evidence-key.test.mjs +++ b/tests/evidence-key.test.mjs @@ -1,3 +1,4 @@ -import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';import {execFileSync} from 'node:child_process';import {randomBytes} from 'node:crypto';import {assertPrivateEvidenceKey} from '../apps/server/evidence-key.mjs';import {normalizePrincipal,principalForGroups} from '../apps/server/admin-access.mjs'; +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';import {randomBytes} from 'node:crypto';import {assertPrivateEvidenceKey} from '../apps/server/evidence-key.mjs';import {normalizePrincipal,principalForGroups} from '../apps/server/admin-access.mjs'; test('analyst identity grants evidence/triage but no policy or fleet administration',()=>{const p=normalizePrincipal({sub:'soc',role:'analyst'});assert.ok(p.permissions.includes('evidence.read'));for(const permission of ['policy.write','devices.manage','sessions.manage','integrations.manage'])assert.equal(p.permissions.includes(permission),false);assert.equal(principalForGroups({sub:'soc'},['soc-team'],{oidcAdminGroup:'admins',oidcAnalystGroup:'soc-team'}).role,'analyst');}); +// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup. test('Windows evidence key rejects inherited shared read permissions',{skip:process.platform!=='win32'},t=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-acl-')),file=join(dir,'evidence.key');try{writeFileSync(file,randomBytes(32));let rejected=false;try{assertPrivateEvidenceKey(file);}catch{rejected=true;}if(!rejected){t.skip('This Windows temp directory already supplies private inherited ACLs');return;}assert.equal(rejected,true);}finally{if(!resolve(dir).startsWith(resolve(tmpdir())+sep))throw Error('Unsafe cleanup');rmSync(dir,{recursive:true,force:true});}}); diff --git a/tests/evidence.test.mjs b/tests/evidence.test.mjs index 54c2740..0a36ab4 100644 --- a/tests/evidence.test.mjs +++ b/tests/evidence.test.mjs @@ -8,7 +8,7 @@ async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}}); test('human observations bind exact event action and session without claiming transfer completion',async t=>{ - const {api,store,id}=await fixture(t),sessionId=randomUUID(); + const {api}=await fixture(t),sessionId=randomUUID(); for(const action of ['click','submit']){ const eventId=randomUUID(),payload={pageUrl:'https://intranet.example/',action,sessionId,outcome:'observed',captureSource:'human-observer',sensitiveField:false,target:{tag:'BUTTON'}}; assert.equal((await api('/api/device/events','POST',{eventId,kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome:'observed'}})).status,200); diff --git a/tests/inspect-text-representations.test.mjs b/tests/inspect-text-representations.test.mjs new file mode 100644 index 0000000..aa6bc9b --- /dev/null +++ b/tests/inspect-text-representations.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +const {inspectTextRepresentations}=createRequire(import.meta.url)('../apps/browser/inspect-text-representations.cjs'); +test('decoded representations reach semantic scanner separately and block before external release',async()=>{ + const policy=defaultPolicy();policy.dlp.semantic.enabled=true;let calls=0,external=0; + const secret='Synthetic confidential paraphrase'; + const result=await inspectTextRepresentations({bytes:Buffer.from(Buffer.from(secret).toString('base64')),policy,isCurrent:()=>true,inspectLocal:async data=>{calls++;return data.toString()===secret?{decision:'block',ruleId:'semantic-secret'}:{decision:'allow',coverage:{complete:true}};},inspectEnterprise:async()=>{external++;return {decision:'allow',coverage:{complete:true}};}}); + assert.equal(result.ruleId,'semantic-secret');assert.equal(calls,2);assert.equal(external,1); +}); +test('policy changes and incomplete scanner coverage fail closed',async()=>{ + const policy=defaultPolicy();policy.dlp.semantic.enabled=true; + let current=true,external=0; + const result=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>current,inspectLocal:async()=>{current=false;return {decision:'allow',coverage:{complete:true}};},inspectEnterprise:async()=>{external++;}}); + assert.equal(result.ruleId,'inspection-context-changed');assert.equal(external,0); + const incomplete=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>true,inspectLocal:async()=>({decision:'allow'})}); + assert.equal(incomplete.ruleId,'inspection-incomplete'); +}); + +test('configured enterprise scanner cannot be silently omitted',async()=>{ + const policy=defaultPolicy();policy.enterprise.icap.enabled=true; + const result=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>true}); + assert.equal(result.decision,'block');assert.equal(result.ruleId,'icap-unavailable'); +}); diff --git a/tests/local-dlp-relay.test.mjs b/tests/local-dlp-relay.test.mjs new file mode 100644 index 0000000..93b080b --- /dev/null +++ b/tests/local-dlp-relay.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test';import assert from 'node:assert/strict';import http from 'node:http';import https from 'node:https';import net from 'node:net';import tls from 'node:tls';import {readFileSync} from 'node:fs';import {createRequire} from 'node:module'; +const {startLocalDlpRelay}=createRequire(import.meta.url)('../apps/browser/local-dlp-relay.cjs'); +async function listening(server,t){server.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections?.();await new Promise(r=>server.close(r));});return server.address().port;} +function basic(relay){const c=relay.credentials();return 'Basic '+Buffer.from(c.username+':'+c.password).toString('base64');} +function get(relay,target,auth=basic(relay)){return new Promise((resolve,reject)=>{const [host,port]=relay.proxyRules.split(':'),req=http.request({host,port,path:target,headers:{host:new URL(target).host,...(auth?{'proxy-authorization':auth}:{})}},res=>{let text='';res.on('data',b=>text+=b);res.on('end',()=>resolve({status:res.statusCode,text}));});req.on('error',reject);req.end();});} +function connect(relay,target){return new Promise((resolve,reject)=>{const [host,port]=relay.proxyRules.split(':'),socket=net.connect({host,port});let bytes=Buffer.alloc(0);socket.on('error',reject);socket.on('data',function header(chunk){bytes=Buffer.concat([bytes,chunk]);if(bytes.indexOf('\r\n\r\n')<0)return;socket.removeListener('data',header);if(!bytes.toString().startsWith('HTTP/1.1 200')){socket.destroy();reject(Error('CONNECT refused'));return;}resolve(socket);});socket.once('connect',()=>socket.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: ${basic(relay)}\r\n\r\n`));});} +async function fixture(t,overrides={}){const authority={version:1},addresses=[];const relay=await startLocalDlpRelay({authorize:async()=>authority,isCurrent:a=>a===authority,resolveUpstream:async()=> 'DIRECT',resolveAddress:async host=>{addresses.push(host);return '127.0.0.1';},...overrides});t.after(()=>relay.close());return {relay,authority,addresses};} +test('private loopback relay authenticates capability and forwards approved HTTP without leaking proxy credentials',async t=>{ + let hits=0,headers;const port=await listening(http.createServer((req,res)=>{hits++;headers=req.headers;res.end('public receiver');}),t),f=await fixture(t),url=`http://fixture.invalid:${port}/public`; + assert.equal((await get(f.relay,url,null)).status,407);assert.equal(hits,0);assert.equal((await get(f.relay,url,'Basic forged')).status,407); + const result=await get(f.relay,url);assert.equal(result.status,200);assert.equal(result.text,'public receiver');assert.equal(hits,1);assert.equal(headers['proxy-authorization'],undefined);assert.deepEqual(f.addresses,['fixture.invalid']);assert.equal(f.relay.proxyBypassRules,'<-loopback>'); +}); +test('native CONNECT tunnel carries actual TLS application data and remains private',async t=>{ + const certificate=readFileSync('tests/fixtures/icap/cert.pem'),key=readFileSync('tests/fixtures/icap/key.pem');let hits=0; + const port=await listening(https.createServer({cert:certificate,key},(_req,res)=>{hits++;res.end('public TLS receiver');}),t),f=await fixture(t),socket=await connect(f.relay,`127.0.0.1:${port}`); + const secure=tls.connect({socket,ca:certificate,servername:'localhost'});let response='';await new Promise((resolve,reject)=>{secure.on('error',reject);secure.once('secureConnect',()=>secure.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'));secure.on('data',b=>response+=b);secure.once('end',resolve);});assert.match(response,/public TLS receiver/);assert.equal(hits,1); +}); +test('PAC selected upstream proxy is preserved, no target DNS is performed and unsupported SOCKS never falls back',async t=>{ + let hits=0;const proxyPort=await listening(http.createServer((req,res)=>{hits++;assert.equal(req.url,'http://enterprise.invalid:8080/public');assert.equal(req.headers['proxy-authorization'],'Basic '+Buffer.from('corporate:user-password').toString('base64'));res.end('enterprise forwarded');}),t); + const f=await fixture(t,{resolveUpstream:async()=>`PROXY proxy.enterprise.invalid:${proxyPort}; DIRECT`,credentialsForProxy:async()=>({username:'corporate',password:'user-password'})});assert.equal((await get(f.relay,'http://enterprise.invalid:8080/public')).text,'enterprise forwarded');assert.deepEqual(f.addresses,['proxy.enterprise.invalid']);assert.equal(hits,1); + const unsupported=await fixture(t,{resolveUpstream:async()=> 'SOCKS5 proxy.enterprise.invalid:1080; DIRECT'});assert.equal((await get(unsupported.relay,'http://enterprise.invalid:8080/public')).status,502);assert.equal(unsupported.addresses.length,0); +}); +test('policy invalidation during async resolution denies transmission and existing tunnels are closed',async t=>{ + let release,started;const begun=new Promise(r=>started=r),waiting=new Promise(r=>release=r);let hits=0; + const port=await listening(http.createServer((_req,res)=>{hits++;res.end('should not release');}),t),f=await fixture(t,{resolveUpstream:async()=>{started();return waiting;}}); + const pending=get(f.relay,`http://fixture.invalid:${port}/`);await begun;f.relay.invalidate();release('DIRECT');await assert.rejects(pending);assert.equal(hits,0); + const sockets=new Set(),echo=net.createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('data',data=>socket.write(data));socket.once('close',()=>sockets.delete(socket));}),echoPort=await listening(echo,t),g=await fixture(t),socket=await connect(g.relay,`fixture.invalid:${echoPort}`);await new Promise(resolve=>{socket.once('data',data=>{assert.equal(data.toString(),'public echo');resolve();});socket.write('public echo');});const ended=new Promise(r=>socket.once('close',r));g.relay.invalidate();await ended;assert.equal(g.relay.status().connections,0);for(const s of sockets)s.destroy(); +}); +test('resolver authority errors and nonnumeric addresses fail closed before target connection',async t=>{ + const f=await fixture(t,{resolveAddress:async()=> 'unapproved.example'});assert.equal((await get(f.relay,'http://target.invalid:8080/')).status,502); + const g=await fixture(t,{isCurrent:()=>{throw Error('Policy unavailable');}});assert.equal((await get(g.relay,'http://target.invalid:8080/')).status,502);assert.equal(g.addresses.length,0); +}); +test('upstream proxy challenge does not impersonate local relay authentication and resolution hangs expire',async t=>{ + const port=await listening(http.createServer((_req,res)=>{res.writeHead(407,{'proxy-authenticate':'Basic realm="untrusted upstream"'});res.end('do not expose authentication data');}),t); + const f=await fixture(t,{resolveUpstream:async()=>`PROXY proxy.invalid:${port}`});const result=await get(f.relay,'http://target.invalid:8080/');assert.equal(result.status,502);assert.equal(result.text,''); + const g=await fixture(t,{timeoutMs:100,resolveUpstream:async()=>new Promise(()=>{})});await assert.rejects(get(g.relay,'http://target.invalid:8080/'));assert.equal(g.addresses.length,0); +}); diff --git a/tests/mvp-main-boundaries.test.mjs b/tests/mvp-main-boundaries.test.mjs new file mode 100644 index 0000000..69a41b4 --- /dev/null +++ b/tests/mvp-main-boundaries.test.mjs @@ -0,0 +1,7 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'),start=source.indexOf('readClipboard:async limit=>')+'readClipboard:'.length,end=source.indexOf('writeClipboard:',start);const code=source.slice(start,end).trim().replace(/,$/,''); +function fixture(types,text='PUBLIC',size=6){const calls=[],blob={size,text:async()=>text},item={types,getType:async type=>{calls.push(type);if(type!=='text/plain')throw Error('HTML must not be read');return blob;}},read=vm.runInNewContext('('+code+')',{clipboard:{read:async()=>[item]}});return {read,calls};} +test('rich HTML clipboard exports only independently bounded inert plain text',async()=>{const f=fixture(['text/plain','text/html','electron application/osclipboard;format="HTML Format"']);assert.equal(await f.read(1024),'PUBLIC');assert.deepEqual(f.calls,['text/plain']);}); +test('files images and unsupported rich clipboard formats remain denied',async()=>{for(const types of [['text/html'],['text/plain','image/png'],['text/plain','electron application/osclipboard;format="CF_HDROP"'],['text/plain','application/rtf']]){const f=fixture(types);await assert.rejects(f.read(1024));assert.deepEqual(f.calls,[]);}}); +test('plain representation size is bounded before it is read',async()=>{const f=fixture(['text/plain','text/html'],'PUBLIC',2048);await assert.rejects(f.read(1024));assert.deepEqual(f.calls,['text/plain']);}); +test('WebSocket handshake is denied even when an old central policy enables the unsupported flag',()=>{assert.match(source,/if\(details\.resourceType==='webSocket'\)\{audit\('dlp\.block','websocket-disabled'/);assert.doesNotMatch(source,/details\.resourceType==='webSocket'&&!policy\.browser\.allowWebSockets/);}); + diff --git a/tests/mvp-network-pipeline.test.mjs b/tests/mvp-network-pipeline.test.mjs new file mode 100644 index 0000000..a067eed --- /dev/null +++ b/tests/mvp-network-pipeline.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import {createRequire} from 'node:module';import vm from 'node:vm';import * as core from '../packages/core/policy.mjs';const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');const wrapper=source.slice(source.indexOf('function inspectOutgoingText('),source.indexOf('function responsePolicyHeaders(')),headers=source.slice(source.indexOf('function responsePolicyHeaders('),source.indexOf('function hooks(){')),gate=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequest({urls:['']},(details,callback)=>{"),source.indexOf(" browserSession.webRequest.onBeforeSendHeaders({urls:['']},(details,callback)=>{")); +function fixture(){const policy=core.defaultPolicy();policy.security.visualPhishing.enabled=false;policy.dlp.semantic.enabled=true;const calls=[],events=[];let listener,current=true;const contents={getURL:()=> 'https://example.com/app',isDestroyed:()=>false},tab={id:'tab1',guarded:true,security:{},view:{webContents:contents}},context=vm.createContext({require:id=>id.startsWith('./')?require('../apps/browser/'+id.slice(2)):require(id.startsWith('../../')?'../'+id.slice(6):id),Buffer,URL,Date,setTimeout,clearTimeout,core,policy,ready:true,localInspector:{inspect:async data=>{const value=data.toString();calls.push({kind:'local',value});return value==='Synthetic confidential paraphrase'?{decision:'block',ruleId:'semantic-secret'}:{decision:'allow',coverage:{complete:true}};}},scanEnterprise:async data=>{calls.push({kind:'icap',value:data.toString()});return {decision:'allow',coverage:{complete:true}};},diagnosticSpan:(_name,_facts,fn)=>fn(),requestDiagnostic:(_details,_facts,fn)=>fn(),browserSession:{webRequest:{onBeforeRequest:(_filter,fn)=>listener=fn}},developerAssetRequestAllowed:()=>false,tabs:{fromContents:()=>tab,all:()=>[tab]},tabContext:()=>({webContents:contents}),currentTabContext:()=>current,agentRequestOwnerAllowed:()=>true,actorDestinationAllowed:()=>true,credentialBroker:null,recentRequests:new Map(),dnsGate:async()=>calls.push({kind:'dns'}),audit:async(kind,ruleId)=>events.push({kind,ruleId}),checkVisual:async()=>({decision:'allow'}),icapRequired:()=>true,emit:()=>{},message:'',uploadLeaseFatal:false,diagnosticCorrelation:()=>({}),credentialRequestCorrelations:new Map()});vm.runInContext(wrapper+gate,context);const run=details=>new Promise((resolve,reject)=>{const deadline=setTimeout(()=>reject(Error('Gate timeout')),3000);listener({...details,webContentsId:1,resourceType:details.resourceType||'xhr'},result=>{clearTimeout(deadline);resolve(result);});});return {context,policy,calls,events,run,invalidate:()=>current=false};} +test('actual main full URL scans decoded semantic and ICAP representations before DNS lookup',async()=>{const f=fixture(),encoded=Buffer.from('Synthetic confidential paraphrase').toString('base64'),r=await f.run({id:1,url:'https://example.com/?prompt='+encoded,method:'GET'});assert.equal(r.cancel,true);assert.ok(f.calls.some(c=>c.value==='Synthetic confidential paraphrase'));assert.equal(f.calls.some(c=>c.kind==='dns'),false);assert.ok(f.events.some(e=>e.ruleId==='semantic-secret'));}); +test('actual main benign URL and encoded text body pass normal inspection while decoded secrets do not',async()=>{for(const secret of [false,true]){const f=fixture(),text=secret?'Synthetic confidential paraphrase':'PUBLIC_TEXT',bytes=Buffer.from(JSON.stringify({prompt:Buffer.from(text).toString('base64')})),result=await f.run({id:2,url:'https://example.com/upload',method:'POST',uploadData:[{bytes}]});assert.equal(result.cancel,secret);assert.ok(f.calls.some(c=>c.value===text));assert.ok(f.calls.some(c=>c.kind==='dns'));if(!secret)assert.ok(f.events.some(e=>e.kind==='upload.authorized'));}}); +test('actual main URL scan policy change prevents DNS and network release',async()=>{const f=fixture();f.context.scanEnterprise=async()=>{f.invalidate();return {decision:'allow',coverage:{complete:true}};};assert.equal((await f.run({id:1,url:'https://example.com/public',method:'GET'})).cancel,true);assert.equal(f.calls.some(c=>c.kind==='dns'),false);}); +test('native connection header retains existing site restrictions and forces WebRTC block on the first list',()=>{const context=vm.createContext({});vm.runInContext(headers,context);const input={'Content-Security-Policy':["default-src 'self'"],'connection-allowlist':['("https://restricted.example"); redirects=block; webrtc=allow, ("https://ignored.example"); webrtc=allow']},result=context.responsePolicyHeaders(input);assert.equal(result['Content-Security-Policy'],input['Content-Security-Policy']);assert.match(result['Connection-Allowlist'][0],/^\("https:\/\/restricted\.example"\); redirects=block; webrtc=allow; webrtc=block,/);assert.equal(input['connection-allowlist'][0].includes('; webrtc=block'),false);assert.match(context.responsePolicyHeaders({})['Connection-Allowlist'][0],/webrtc=block$/);}); + +test('header parser preserves quoted commas and parameters without weakening site endpoint restrictions',()=>{const context=vm.createContext({});vm.runInContext(headers,context);const original='("https://restricted.example/a,b?q=(x)"); note=";webrtc=allow"; webrtc=allow, ("https://ignored.example")';const result=context.responsePolicyHeaders({'Connection-Allowlist':[original]})['Connection-Allowlist'][0];assert.equal(result,'("https://restricted.example/a,b?q=(x)"); note=";webrtc=allow"; webrtc=allow; webrtc=block, ("https://ignored.example")');assert.throws(()=>context.responsePolicyHeaders({'Connection-Allowlist':['("unterminated)']}));}); +test('registered secret hostname is blocked before any explicit DNS resolution',async()=>{const f=fixture();f.policy.dlp.rules=[{id:'hostname-secret',kind:'literal',value:'synthetic-company-secret'}];assert.equal((await f.run({id:3,url:'https://synthetic-company-secret.example/path',method:'GET'})).cancel,true);assert.equal(f.calls.some(c=>c.kind==='dns'),false);assert.ok(f.events.some(e=>e.ruleId==='hostname-secret'));}); diff --git a/tests/package-fuses.test.mjs b/tests/package-fuses.test.mjs new file mode 100644 index 0000000..60b7209 --- /dev/null +++ b/tests/package-fuses.test.mjs @@ -0,0 +1,6 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {hardenedElectronFuses,assertEmbeddedIntegrity} from '../scripts/package-fuses.mjs'; +test('production fuses close alternate Node entry points and require encrypted cookies and ASAR integrity',()=>{assert.deepEqual(hardenedElectronFuses,{runAsNode:false,enableCookieEncryption:true,enableNodeOptionsEnvironmentVariable:false,enableNodeCliInspectArguments:false,enableEmbeddedAsarIntegrityValidation:true,onlyLoadAppFromAsar:true});}); +test('Windows integrity metadata binds exact packaged archive header and rejects missing duplicate or altered values',()=>{const header='synthetic header',entry={file:'resources\\app.asar',alg:'SHA256',value:createHash('sha256').update(header).digest('hex')};assert.doesNotThrow(()=>assertEmbeddedIntegrity([entry],header));for(const entries of [[],[entry,entry],[{...entry,file:'app.asar'}],[{...entry,alg:'MD5'}],[{...entry,value:'0'.repeat(64)}]])assert.throws(()=>assertEmbeddedIntegrity(entries,header));assert.throws(()=>assertEmbeddedIntegrity([entry],header+'tampered'));}); diff --git a/tests/policy-stage-diagnostics.test.mjs b/tests/policy-stage-diagnostics.test.mjs index 1312d22..f2f2acd 100644 --- a/tests/policy-stage-diagnostics.test.mjs +++ b/tests/policy-stage-diagnostics.test.mjs @@ -21,6 +21,8 @@ async function fixture(t){ const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)}); const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});}; const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')}); + c.localRelay={proxyRules:'127.0.0.1:45678',proxyBypassRules:'<-loopback>',invalidate:tracked('relay-invalidate')}; + const originalSetProxy=c.browserSession.setProxy;c.browserSession.setProxy=async settings=>{assert.equal(settings.mode,'fixed_servers');assert.equal(settings.proxyRules,c.localRelay.proxyRules);assert.equal(settings.proxyBypassRules,'<-loopback>');await originalSetProxy(settings);}; vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])}; } test('actual unchanged policy exports correlated verification, bookmark and agent child stages',async t=>{ diff --git a/tests/proxy-credentials.test.mjs b/tests/proxy-credentials.test.mjs new file mode 100644 index 0000000..24c77f6 --- /dev/null +++ b/tests/proxy-credentials.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {deploymentProxyCredentials}=createRequire(import.meta.url)('../apps/browser/proxy-credentials.cjs'); +test('deployment proxy credentials require an exact host and port pin',()=>{ + const env={SAFETYNET_PROXY_USER:'fixture-user',SAFETYNET_PROXY_PASSWORD:'fixture-password',SAFETYNET_PROXY_AUTH_HOST:'proxy.company.example',SAFETYNET_PROXY_AUTH_PORT:'8443'}; + assert.deepEqual(deploymentProxyCredentials({host:'PROXY.COMPANY.EXAMPLE',port:8443},env),{username:'fixture-user',password:'fixture-password'}); + for(const selected of [{host:'attacker.example',port:8443},{host:'proxy.company.example',port:8080}])assert.equal(deploymentProxyCredentials(selected,env),null); + assert.equal(deploymentProxyCredentials({host:'proxy.company.example',port:8443},{...env,SAFETYNET_PROXY_AUTH_HOST:undefined}),null); +}); diff --git a/tests/request-gate-timeout.test.mjs b/tests/request-gate-timeout.test.mjs index 7313f46..aa57a6b 100644 --- a/tests/request-gate-timeout.test.mjs +++ b/tests/request-gate-timeout.test.mjs @@ -7,25 +7,26 @@ import {defaultPolicy} from '../packages/core/policy.mjs'; const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'); const hook=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequest("),source.indexOf(" browserSession.webRequest.onBeforeSendHeaders(")); function fixture(){ - let listener,completeDNS;const timers=[],events=[],callbacks=[],policy=defaultPolicy(); + let listener,completeDNS,enteredDNS;const dnsReady=new Promise(resolve=>enteredDNS=resolve);const timers=[],events=[],callbacks=[],policy=defaultPolicy(); const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),URL,Date,ready:true,policy, browserSession:{webRequest:{onBeforeRequest(_filter,fn){listener=fn;}}},tabs:{all:()=>[]},credentialBroker:undefined,credentialRequestCorrelations:new Map(),currentTabContext:()=>true, developerAssetRequestAllowed:()=>false,diagnosticSpan:(_name,_attrs,fn)=>fn(),core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})}, - audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>completeDNS=resolve), + audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>{completeDNS=resolve;enteredDNS();}),localInspector:undefined,scanEnterprise:async()=>({decision:'allow',coverage:{complete:true}}), setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){},Buffer, }); vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context); vm.runInContext(source.slice(source.indexOf('function requestDiagnostic('),source.indexOf('function credentialContext(')),context); + vm.runInContext(source.slice(source.indexOf('function inspectOutgoingText('),source.indexOf('function responsePolicyHeaders(')),context); vm.runInContext(hook,context);listener({id:1,url:'https://example.test/',webContentsId:0,resourceType:'mainFrame',method:'GET'},r=>callbacks.push(r)); - return {timers,events,callbacks,complete:()=>completeDNS()}; + return {timers,events,callbacks,dnsReady,complete:()=>completeDNS()}; } const settle=async()=>{for(let n=0;n<8;n++)await Promise.resolve();}; test('native request deadline records a fixed reason and never authorizes a late result',async()=>{ - const f=fixture();await settle();f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true); + const f=fixture();await f.dnsReady;await settle();f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true); assert.deepEqual(f.events,[['navigation.block','request-inspection-timeout','example.test']]);f.complete();await settle();f.timers[0]();await settle(); assert.equal(f.callbacks.length,1);assert.equal(f.events.length,1); }); test('a completed native request cannot subsequently produce a timeout event',async()=>{ - const f=fixture();await settle();f.complete();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false); + const f=fixture();await f.dnsReady;await settle();f.complete();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false); f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.events.length,0); }); diff --git a/tests/session-snapshot.test.mjs b/tests/session-snapshot.test.mjs index b170bf2..1d83fd4 100644 --- a/tests/session-snapshot.test.mjs +++ b/tests/session-snapshot.test.mjs @@ -14,4 +14,4 @@ test('session bounds reject oversized and structurally invalid collections',()=> test('benign query and fragment destinations retain exact navigation semantics',()=>{const value=sample();value.tabs[0].url='https://example.test/search?q=research&page=2#results';assert.equal(sanitizeSessionCollection(value).tabs[0].url,value.tabs[0].url);}); test('persistence checks authority after protection before committing ciphertext',()=>withStore(({store,options,filePath})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const before=fs.readFileSync(filePath);let permitted=true;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);permitted=false;return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Forbidden after change'},{...owner,expectedRevision:store.list(owner).revision,assertCurrent:()=>{if(!permitted)throw Error('Authority changed');}}));assert.deepEqual(fs.readFileSync(filePath),before);assert.equal(store.list(owner).collections.length,1);})); -test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options,filePath})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);})); +test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);})); diff --git a/tests/startup-exit.test.mjs b/tests/startup-exit.test.mjs new file mode 100644 index 0000000..a2df573 --- /dev/null +++ b/tests/startup-exit.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +test('actual main startup exits with fixed diagnostics before unsafe runtime initialization',()=>{ + const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'); + const prefix=source.slice(0,source.indexOf('// Force Chromium sandboxing')); + const exits=[],messages=[],sentinel=Error('Fixture process terminated'); + const context={require:name=>name==='electron'?{app:{commandLine:{hasSwitch:flag=>flag==='inspect'}}}:require('../apps/browser/'+name.slice(2)),process:{exit:code=>{exits.push(code);throw sentinel;}},console:{error:message=>messages.push(message)}}; + assert.throws(()=>vm.runInNewContext(prefix,context),error=>error===sentinel); + assert.deepEqual(exits,[1]);assert.deepEqual(messages,['Managed browser startup denied']); +}); diff --git a/tests/startup-security.test.mjs b/tests/startup-security.test.mjs index ba80635..940511c 100644 --- a/tests/startup-security.test.mjs +++ b/tests/startup-security.test.mjs @@ -21,3 +21,10 @@ test('broken command-line adapters fail closed with a static error',()=>{ test('an already-active Node inspector is rejected even when no argv flag remains',()=>{ const inspector=require('node:inspector'),original=inspector.url;try{inspector.url=()=> 'ws://private-main-debugger.internal:9229/secret';assert.throws(()=>assertSecureStartup(commandLine([]),[]),{message:'Unsafe browser startup configuration'});}finally{inspector.url=original;} }); +test('user feature overrides cannot disable native enforcement and never enter errors',()=>{ + for(const flag of ['--disable-features=PrivateFeature','--disable-blink-features=PrivateFeature','--enable-features=PrivateFeature','--enable-blink-features=PrivateFeature','--enable-experimental-web-platform-features','-disable-features=PrivateFeature','/disable-features=PrivateFeature','/DISABLE-FEATURES=PrivateFeature',' --disable-features=PrivateFeature ','\u0085/disable-features=PrivateFeature\u0085']){ + assert.throws(()=>assertSecureStartup(commandLine([]),[],['browser.exe',flag]),{message:'Unsafe browser startup configuration'}); + } + assert.equal(assertSecureStartup(commandLine(['disable-features']),[],['browser.exe','--lang=de']),true,'Electron internal defaults are not user overrides'); + assert.throws(()=>assertSecureStartup(commandLine([]),[],[null]),{message:'Unsafe browser startup configuration'}); +}); diff --git a/tests/text-variants.test.mjs b/tests/text-variants.test.mjs new file mode 100644 index 0000000..d28009f --- /dev/null +++ b/tests/text-variants.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {defaultPolicy,inspect} from '../packages/core/policy.mjs'; +import {inspectionTextVariants} from '../packages/core/text-variants.mjs'; +test('literal secrets are detected in common nested wire encodings',()=>{ + const p=defaultPolicy();p.dlp.rules=[{id:'company-secret',label:'Secret',kind:'literal',value:'PROJECT REDWOOD',action:'block'}]; + const secret='PROJECT REDWOOD',b64=Buffer.from(secret).toString('base64'); + for(const text of [secret,b64,JSON.stringify({payload:b64}),encodeURIComponent(encodeURIComponent(secret)),JSON.stringify({prompt:'PROJECT\u0020REDWOOD'}),Buffer.from(b64).toString('base64')])assert.equal(inspect(Buffer.from(text),p).ruleId,'company-secret',text); + for(const text of ['Public meeting agenda',JSON.stringify({prompt:'Public question',count:3}),'https://public.example/query?q=public+text'])assert.equal(inspect(Buffer.from(text),p).decision,'allow'); +}); +test('normalization is bounded and fails closed instead of skipping inspection',()=>{ + const p=defaultPolicy();const text=JSON.stringify(Array.from({length:80},(_,i)=>`Public text ${i}`)); + assert.equal(inspect(Buffer.from(text),p).ruleId,'normalization-limit'); + assert.equal(inspect(Buffer.from(JSON.stringify(Array.from({length:5000},(_,i)=>i))),p).ruleId,'normalization-limit'); + assert.throws(()=>inspectionTextVariants('Public text',2)); + let nested='PROJECT REDWOOD';for(let i=0;i<4;i++)nested=Buffer.from(nested).toString('base64'); + assert.equal(inspect(Buffer.from(nested),p).ruleId,'normalization-limit','Excessive encoding depth must not silently skip decoding'); +}); diff --git a/tests/transport-deny.test.mjs b/tests/transport-deny.test.mjs new file mode 100644 index 0000000..c611ab1 --- /dev/null +++ b/tests/transport-deny.test.mjs @@ -0,0 +1,5 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';const source=readFileSync(new URL('../apps/browser/transport-deny.cjs',import.meta.url),'utf8'),definition=source.slice(source.indexOf('function denyUninspectedRTC(){'),source.indexOf('\nif(process.sandboxed')); +function world(){const context=vm.createContext({DOMException,RTCPeerConnection:function Native(){},webkitRTCPeerConnection:function NativeAlias(){},mozRTCPeerConnection:function OtherAlias(){}});vm.runInContext(definition+';denyUninspectedRTC();',context);return context;} +test('unsupported RTC constructors and aliases fail with fixed NotAllowedError before native invocation',()=>{const c=world();for(const name of ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection'])assert.throws(()=>vm.runInContext('new '+name+'()',c),e=>e.name==='NotAllowedError'&&e.message==='Managed WebRTC inspection is unavailable');}); +test('globals and their prototype constructor cannot expose or restore the native constructor',()=>{const c=world();assert.equal(vm.runInContext('Object.getOwnPropertyDescriptor(globalThis,"RTCPeerConnection").writable',c),false);assert.equal(vm.runInContext('Object.getOwnPropertyDescriptor(globalThis,"RTCPeerConnection").configurable',c),false);assert.throws(()=>vm.runInContext('new RTCPeerConnection.prototype.constructor()',c),e=>e.name==='NotAllowedError');assert.throws(()=>vm.runInContext('Object.defineProperty(globalThis,"RTCPeerConnection",{value:function(){}})',c));vm.runInContext('RTCPeerConnection=function(){}',c);assert.throws(()=>vm.runInContext('new RTCPeerConnection()',c),e=>e.name==='NotAllowedError');}); +test('preload rejects missing sandbox and installs only the fixed main-world function without a page bridge',()=>{for(const secure of [false,true]){let sends=0,stops=0,installed=0;const c=vm.createContext({process:{sandboxed:secure,contextIsolated:secure},window:{stop:()=>stops++},require:()=>({ipcRenderer:{send:()=>sends++},contextBridge:{executeInMainWorld:({func})=>{assert.equal(func.name,'denyUninspectedRTC');installed++;}}})});if(secure){vm.runInContext(source,c);assert.equal(installed,1);assert.equal(sends,0);}else{assert.throws(()=>vm.runInContext(source,c));assert.equal(installed,0);assert.equal(sends,1);assert.equal(stops,1);}}assert.doesNotMatch(source,/exposeInMainWorld|ipcRenderer\.invoke|node:|fs\./);}); diff --git a/tests/user-profiles.test.mjs b/tests/user-profiles.test.mjs index 55894da..7701ac5 100644 --- a/tests/user-profiles.test.mjs +++ b/tests/user-profiles.test.mjs @@ -14,3 +14,5 @@ test('domain categories and intersected host allowances fail closed for disjoint test('profile publication enforces independent reviewers, role permissions, CAS, snapshots and restore',async t=>{const f=await fixture(t,true),profile=UserProfileSchema.parse({id:'executive',name:'Board',deviceIds:['one'],requireDocumentScan:true});const queued=await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.getRaw('userProfiles'),null);const body={payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true};assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'operator')).status,403);assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'reviewer')).status,200);assert.equal((await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile})).status,409);assert.equal((await f.api('/api/admin/user-profiles/executive','DELETE',{expectedVersion:2},'auditor','auditor')).status,403);assert.equal((await f.api('/api/admin/policy/history/2')).body.userProfiles.profiles[0].id,'executive');const restore=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(restore.status,202);assert.equal((await f.api(`/api/admin/policy-reviews/${restore.body.reviewId}/approve`,'POST',{payloadHash:restore.body.payloadHash,expectedVersion:2,approve:true},'reviewer')).status,200);assert.deepEqual(f.store.getRaw('userProfiles').profiles,[]);}); test('enrollment cannot forge a verified SSO subject through client input or a named provisioning code',async t=>{const f=await fixture(t);const code='synthetic-provisioning-code-123456789';f.store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'employee');const r=await fetch(f.base+'/api/device/enroll',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code,name:'Forged label',platform:'win32',sub:'employee',verified_subject:'employee',user:'employee'})});assert.equal(r.status,200);const b=await r.json(),d=f.store.db.prepare('SELECT verified_subject,user FROM devices WHERE id=?').get(b.id);assert.equal(d.verified_subject,null);assert.equal(d.user,'employee');}); + +test('agent background worker profiles only restrict and cannot widen central permission',()=>{const p=defaultPolicy(),inherit=UserProfileSchema.parse({id:'inherit',name:'Inherit',deviceIds:['one']}),deny=UserProfileSchema.parse({id:'deny',name:'Deny',deviceIds:['one'],features:{agentBackgroundWorkers:'deny'}});assert.equal(inherit.features.agentBackgroundWorkers,'inherit');assert.throws(()=>UserProfileSchema.parse({...deny,features:{agentBackgroundWorkers:'allow'}}));p.browser.allowAgentBackgroundWorkers=false;assert.equal(applyUserProfiles(p,{profiles:[inherit]},{id:'one'}).browser.allowAgentBackgroundWorkers,false);p.browser.allowAgentBackgroundWorkers=true;assert.equal(applyUserProfiles(p,{profiles:[inherit]},{id:'one'}).browser.allowAgentBackgroundWorkers,true);assert.equal(applyUserProfiles(p,{profiles:[inherit,deny]},{id:'one'}).browser.allowAgentBackgroundWorkers,false);assert.deepEqual(applyUserProfiles(p,{profiles:[inherit,deny]},{id:'one'}),applyUserProfiles(p,{profiles:[deny,inherit]},{id:'one'}));assert.equal(applyUserProfiles(p,{profiles:[deny]},{id:'other'}).browser.allowAgentBackgroundWorkers,true);}); diff --git a/tests/worker-policy-boundary.test.mjs b/tests/worker-policy-boundary.test.mjs new file mode 100644 index 0000000..e4557da --- /dev/null +++ b/tests/worker-policy-boundary.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generateKeyPairSync} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {defaultPolicy,PolicySchema,signPolicy,verifyPolicy} from '../packages/core/policy.mjs'; +import {AgentGrantSchema} from '../packages/core/security.mjs'; +const {startAgentControl}=createRequire(import.meta.url)('../apps/browser/agent-control.cjs'); +const {requestAgent}=createRequire(import.meta.url)('../apps/browser/agent-transport-client.cjs'); + +test('worker gate defaults off and survives signed policy verification',()=>{ + const p=defaultPolicy();assert.equal(p.browser.allowAgentBackgroundWorkers,false); + delete p.browser.allowAgentBackgroundWorkers; + assert.equal(PolicySchema.parse(p).browser.allowAgentBackgroundWorkers,false); + p.browser.allowAgentBackgroundWorkers=true; + const {privateKey,publicKey}=generateKeyPairSync('ed25519',{privateKeyEncoding:{type:'pkcs8',format:'pem'},publicKeyEncoding:{type:'spki',format:'pem'}}); + assert.equal(verifyPolicy(signPolicy(p,privateKey),publicKey).browser.allowAgentBackgroundWorkers,true); + assert.equal(PolicySchema.safeParse({...p,browser:{...p.browser,allowAgentBackgroundWorkers:'true'}}).success,false); +}); + +test('background permission cannot become an executable agent RPC',async t=>{ + const grant=AgentGrantSchema.parse({id:'worker-agent',name:'Worker',enabled:true,expiresAt:'2099-12-31T23:59:59Z',scopes:['network:background'],allowedHosts:['company.example'],generation:1}); + assert.deepEqual(grant.scopes,['network:background']);let executed=0; + const api=await startAgentControl({authorize:async()=>({allowed:true,role:'agent',scopes:['network:background']}),actions:{'network:background':async()=>{executed++;}},audit:async()=>{}}); + t.after(()=>api.close()); + assert.equal((await requestAgent(api,{action:'network:background',args:{}})).status,400); + assert.equal(executed,0); +});