diff --git a/README.md b/README.md index 4a1bf82..486652f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a | Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. | | Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. | | AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings and bookmarks. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | -| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata and opt-in completed clipboard/agent-click metadata; general interaction and content recording remain incomplete. | +| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay and content recording remain incomplete. | | Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). @@ -178,7 +178,7 @@ The packaging workflow produces an unpacked evaluation build in `dist/win-unpack An Electron shell does not yet provide complete enterprise DLP across every transport. [Ordinary Windows NTFS file uploads](docs/windows-file-uploads.md) have a tested handle-bound release path; unsupported ranges, reparse/network paths, data pipes and upload redirects remain blocked. WebSocket/WebRTC/streaming, encoded or covert channels, printing, OS screen capture, other applications and local administrator tampering are not fully covered. Complete audio/video transcription and frame coverage are unfinished. -Automatic forensic browser capture, a complete analyst evidence interface, per-agent cryptographic identities, SCIM, production key rotation, tested backup/recovery and independent security testing remain open. An allow decision means no implemented rule matched the supported input; it does not guarantee absence of secrets or malware. +Full forensic browser replay, a complete analyst evidence interface, executable/device cryptographic identity, SCIM, production key rotation, tested backup/recovery and independent security testing remain open. Separate agent credentials and bounded metadata capture are implemented; their Windows same-user trust boundary remains. An allow decision means no implemented rule matched the supported input; it does not guarantee absence of secrets or malware. See the [threat model](docs/threat-model.md) and [requirements audit and roadmap](docs/requirements-audit.md) for coverage and remaining gates. Report security concerns privately to the repository owner; never post credentials or confidential documents in a public issue. @@ -189,4 +189,4 @@ A project distribution license has not yet been selected. Third-party components Security audit verification, optional external ML-DSA-87 signing and retention proof details are documented in [Audit integrity](docs/audit-integrity.md). Independent checkpoint delivery and production signing-key rotation remain under development. -Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. [Interaction evidence](docs/interaction-evidence.md) covers completed guarded copy/cut/paste and agent clicks using bounded metadata. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); broader content/interaction capture, device identity and production key custody remain under development. +Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. [Interaction evidence](docs/interaction-evidence.md) covers completed guarded copy/cut/paste, agent clicks and observed foreground click/form gestures using bounded metadata. An observed form event does not prove transmission. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); broader content/interaction capture, device identity and production key custody remain under development. diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 9497def..181981b 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -9,9 +9,9 @@ const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs' const {resourceFromAttributes}=require('@opentelemetry/resources'); const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http'); const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http'); -const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api'); +const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api'); const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']); -const operations=new Set(['policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']); +const 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']); const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']); const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']); const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']); @@ -61,8 +61,26 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();} activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}} } + // Durable events may be delivered by a flusher started under another event's + // async context. Adopt only data-property IDs; never inherit that unrelated + // parent, invoke caller getters, or allow diagnostic failure to repeat work. + function withCorrelation(ids,fn){ + if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function'); + const current=generation;let parent=null; + try{ + if(current?.active&&ids&&typeof ids==='object'&&!Array.isArray(ids)&&[Object.prototype,null].includes(Object.getPrototypeOf(ids))){ + const descriptors=Object.getOwnPropertyDescriptors(ids),traceId=descriptors.traceId?.value,spanId=descriptors.spanId?.value; + if(typeof traceId==='string'&&/^[a-f0-9]{32}$/.test(traceId)&&traceId!=='0'.repeat(32)&&typeof spanId==='string'&&/^[a-f0-9]{16}$/.test(spanId)&&spanId!=='0'.repeat(16)){ + const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate; + const sampled=randomInt(1000000){const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})}; + return {configure,span,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})}; } module.exports={createDiagnostics,attributes,endpointAllowed}; diff --git a/apps/browser/evidence-capture.cjs b/apps/browser/evidence-capture.cjs index a7561b4..fed70af 100644 --- a/apps/browser/evidence-capture.cjs +++ b/apps/browser/evidence-capture.cjs @@ -25,7 +25,7 @@ function validateInteractionEvidence(value){ try{const u=new URL(value.pageUrl);return !credentialPatterns.some(p=>p.test(value.pageUrl)||p.test(decodeURIComponent(u.pathname)));}catch{return false;} } function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,outcome,bytes,captureSource},policy){ - if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||outcome!=='completed')return null; + if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||!['completed','observed'].includes(outcome))return null; let page;try{page=new URL(url);if(!['http:','https:'].includes(page.protocol))return null;}catch{return null;} if(target!==undefined){ if(!target||typeof target!=='object'||Array.isArray(target)||Object.keys(target).some(key=>!['tag','role','x','y'].includes(key))||typeof target.tag!=='string')return null; @@ -35,7 +35,7 @@ function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,ou } // Paths can carry identifiers or credentials. Automatic metadata uses origin // only; detailed evidence remains an explicitly governed separate workflow. - const payload={pageUrl:page.origin+'/',action,sessionId,outcome:'completed',sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})}; + const payload={pageUrl:page.origin+'/',action,sessionId,outcome,sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})}; return validateInteractionEvidence(payload)?structuredClone(payload):null; } module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence}; diff --git a/apps/browser/evidence-delivery.cjs b/apps/browser/evidence-delivery.cjs index 488ef4e..716c1d2 100644 --- a/apps/browser/evidence-delivery.cjs +++ b/apps/browser/evidence-delivery.cjs @@ -13,7 +13,7 @@ async function deliverAuditRecord({record,send,getContext}){ const current=state();if(current==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(current==='pause')throw Error('Evidence delivery paused until managed context is ready'); const payload=validatedEvidence(protectedEvidence.payload,type); if(type==='interaction'){ - const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:'agent.action'; + const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:payload.captureSource==='human-observer'?'browser.interaction':'agent.action'; if(event.kind!==expectedKind||payload.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(payload.action)||payload.captureSource==='agent-adapter'&&payload.action!=='click')throw Error('Invalid protected evidence source'); } try{const result=await send('/api/device/evidence',{eventId:event.eventId,type,payload}); diff --git a/apps/browser/human-interaction.cjs b/apps/browser/human-interaction.cjs new file mode 100644 index 0000000..f67a7c0 --- /dev/null +++ b/apps/browser/human-interaction.cjs @@ -0,0 +1,91 @@ +'use strict'; +// Runs only in the privileged isolated world; no preload IPC or page-world bridge. +function humanInteractionDOM(mode,url,token){ + const key='__safetynetHumanInteraction'; + const previous=globalThis[key]; + if(mode==='dispose'){if(previous?.token===token){previous.dispose();delete globalThis[key];}return null;} + if(window.top!==window||location.href!==url)return null; + if(mode==='drain'){ + if(!previous||previous.token!==token)return null; + previous.until=Date.now()+1500;const events=previous.queue.splice(0,16);return {events,dropped:previous.dropped}; + } + if(mode!=='install')return null; + if(previous){previous.dispose();delete globalThis[key];} + const state={token,queue:[],dropped:0,until:Date.now()+1500,tokens:20,last:Date.now(),gesture:null}; + const sensitive=e=>{ + let steps=0; + for(let p=e;p;p=p.parentElement){ + if(++steps>64)return true; + 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(' '))||/^(password|file|hidden)$/i.test(p.type||''))return true; + } + return false; + }; + const formSensitive=e=>{ + const form=e?.tagName==='FORM'?e:e?.form||e?.closest?.('form'); + if(!form)return false; + const controls=form.elements;if(!controls||controls.length>128)return true; + for(let i=0;i{ + const now=Date.now();if(event.isTrusted!==true||now>state.until||location.href!==url||event.type==='submit'&&(navigator.userActivation?.isActive!==true||state.gesture===null||now-state.gesture>1000))return; + const target=event.target?.nodeType===1?event.target:event.target?.parentElement; + if(!target?.isConnected||sensitive(target)||formSensitive(target))return; + if(event.type==='click')state.gesture=now; + state.tokens=Math.min(20,state.tokens+Math.max(0,now-state.last)*0.01);state.last=now; + if(state.tokens<1||state.queue.length>=16){state.dropped=Math.min(1000000,state.dropped+1);return;} + state.tokens--; + const tag=['A','BUTTON','INPUT','TEXTAREA','SELECT','DIV','SPAN','P','BODY'].includes(target.tagName)?target.tagName:'OTHER'; + const facts={tag};const role=target.getAttribute?.('role'); + if(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option'].includes(role))facts.role=role; + if(event.type==='click'&&Number.isFinite(event.clientX)&&Number.isFinite(event.clientY)&&event.clientX>=0&&event.clientY>=0&&event.clientX<=innerWidth&&event.clientY<=innerHeight){facts.x=Math.round(event.clientX);facts.y=Math.round(event.clientY);} + state.queue.push({action:event.type,outcome:'observed',sensitiveField:false,target:facts}); + }; + const enter=event=>{if(event.isTrusted===true&&event.key==='Enter'&&Date.now()<=state.until&&location.href===url&&!sensitive(event.target)&&!formSensitive(event.target))state.gesture=Date.now();}; + window.addEventListener('click',record,true);window.addEventListener('submit',record,true);window.addEventListener('keydown',enter,true); + state.dispose=()=>{window.removeEventListener('click',record,true);window.removeEventListener('submit',record,true);window.removeEventListener('keydown',enter,true);state.queue.length=0;}; + globalThis[key]=state;return {installed:true}; +} +function humanInteractionScript(mode,{url,token}){ + if(!['install','drain','dispose'].includes(mode)||typeof url!=='string'||url.length>4096||typeof token!=='string'||!/^[a-f0-9]{32,64}$/.test(token))throw TypeError('Invalid interaction observer context'); + return `(${humanInteractionDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)})`; +} +module.exports={humanInteractionScript}; + +function createHumanInteractionObserver({getContext,isCurrent,executeScript,onObserved,onDropped=()=>{},interval=250,deadline=1000}){ + if([getContext,isCurrent,executeScript,onObserved,onDropped].some(fn=>typeof fn!=='function'))throw TypeError('Invalid interaction observer callbacks'); + if(!Number.isSafeInteger(interval)||interval<100||interval>500||!Number.isSafeInteger(deadline)||deadline<100||deadline>1000)throw TypeError('Invalid interaction observer timing'); + const {randomBytes}=require('node:crypto');let current=null,retired=null,busy=false,closed=false,epoch=0; + const eligible=c=>!!c&&c.ready===true&&c.enabled===true&&c.foreground===true&&typeof c.sessionId==='string'&&typeof c.url==='string'&&typeof c.token==='string'&&Number.isSafeInteger(c.policyVersion)&&c.webContents&&!c.webContents.isDestroyed?.()&&/^https?:\/\//.test(c.url)&&isCurrent(c); + const same=(a,b)=>a&&b&&a.sessionId===b.sessionId&&a.url===b.url&&a.token===b.token&&a.policyVersion===b.policyVersion&&a.webContents===b.webContents; + const validBatch=result=>result&&typeof result==='object'&&!Array.isArray(result)&&Object.keys(result).every(k=>['events','dropped'].includes(k))&&Array.isArray(result.events)&&result.events.length<=16&&Number.isSafeInteger(result.dropped)&&result.dropped>=0&&result.dropped<=1000000; + async function tick(){ + if(closed||busy)return;busy=true;const generation=epoch;let timedOut=false;const timer=setTimeout(()=>{timedOut=true;epoch++;current=null;},deadline);timer.unref?.(); + try{ + if(retired){const previous=retired;retired=null;await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');if(closed||timedOut||generation!==epoch)return;} + const context=getContext(); + if(!eligible(context)){const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');return;} + if(!same(current?.context,context)){ + const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose'); + if(closed||timedOut||generation!==epoch||!eligible(context))return; + const secret=randomBytes(24).toString('hex');const result=await executeScript(context.webContents,humanInteractionScript('install',{url:context.url,token:secret}),'install'); + if(!closed&&!timedOut&&generation===epoch&&eligible(context)&&result?.installed===true)current={context,secret,dropped:0}; + else if(result?.installed===true)await executeScript(context.webContents,humanInteractionScript('dispose',{url:context.url,token:secret}),'dispose');return; + } + const selected=current;const result=await executeScript(context.webContents,humanInteractionScript('drain',{url:context.url,token:selected.secret}),'drain'); + if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context)||!validBatch(result))return; + if(result.dropped>selected.dropped){await onDropped(result.dropped-selected.dropped,context);selected.dropped=result.dropped;} + for(const event of result.events){ + if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context))return; + // Never trust arbitrary renderer keys; the root validates the shared evidence schema too. + if(!event||typeof event!=='object'||Array.isArray(event)||Object.keys(event).some(k=>!['action','outcome','sensitiveField','target'].includes(k))||!['click','submit'].includes(event.action)||event.outcome!=='observed'||event.sensitiveField!==false)continue; + await onObserved(event,context); + } + }catch{current=null;}finally{clearTimeout(timer);busy=false;} + } + const timer=setInterval(()=>{void tick();},interval);timer.unref?.(); + function refresh(){epoch++;if(current)retired=current;current=null;void tick();} + function dispose(){if(closed)return;closed=true;epoch++;clearInterval(timer);const previous=current||retired;current=null;retired=null;if(previous)Promise.resolve().then(()=>executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose')).catch(()=>{});} + return {refresh,dispose,poll:tick}; +} +module.exports.createHumanInteractionObserver=createHumanInteractionObserver; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index d198bac..fa27398 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -203,7 +203,7 @@ function enforceEvidencePolicy(){ async function flushAudit(){ if(!device||flushing||!auditQueue.length)return;flushing=true; - try{for(let n=0;n<100&&auditQueue.length;n++){await require('./evidence-delivery.cjs').deliverAuditRecord({record:auditQueue[0],send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})});auditQueue.shift();persistAudit();}} + try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}} catch{}finally{flushing=false;} } async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){ @@ -567,15 +567,39 @@ function hooks(){ }); // Session request, header and permission gates are installed exactly once. } +function interactionObserverContext(tab){ + const contents=tab.contents||tab.view.webContents,nativeContext=tabContext(tab.id); + return {ready:ready&¤tTabContext(nativeContext),enabled:policy?.evidence?.enabled===true&&policy.evidence.captureInteractions===true,policyVersion:policy?.version||0,sessionId:tab.id,url:contents.getURL(),token:`${tab.security.interactionDocumentGeneration}:${nativeContext.activation}`,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,webContents:contents,nativeContext,documentGeneration:tab.security.interactionDocumentGeneration}; +} +function currentInteractionObserverContext(context){ + const tab=activeTab(); + if(!context||!ready||!tab||policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||context.policyVersion!==policy.version||Date.parse(policy.expiresAt)<=Date.now()||context.sessionId!==tab.id||context.webContents!==tab.contents||tab.contents.isDestroyed()||context.url!==tab.contents.getURL()||context.documentGeneration!==tab.security.interactionDocumentGeneration||tab.startPage||overviewPanel||toolsOpen)return false; + const live=tabs.context(tab.id); + return !live.paused&&live.activation===context.nativeContext.activation; +} function installSiteHooks(tab){ const contents=tab.view.webContents,security=tab.security; + security.interactionDocumentGeneration=0; + security.interactionObserver=require('./human-interaction.cjs').createHumanInteractionObserver({ + getContext:()=>interactionObserverContext(tab),isCurrent:currentInteractionObserverContext, + executeScript:(target,code,operation)=>diagnosticSpan('browser.evidence',{operation:`evidence.observer.${operation}`,sessionId:tab.id},()=>target.executeJavaScriptInIsolatedWorld(998,[{code}],false)), + onObserved:(event,context)=>diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:context.sessionId},async()=>{ + if(!currentInteractionObserverContext(context))return; + const payload=require('./evidence-capture.cjs').buildInteractionEvidence({...event,sessionId:context.sessionId,url:context.url,captureSource:'human-observer'},policy); + if(!payload||!currentInteractionObserverContext(context))return; + await audit('browser.interaction',undefined,new URL(payload.pageUrl).hostname,undefined,undefined,payload,undefined,{action:payload.action,sessionId:payload.sessionId,outcome:'observed'}); + }), + onDropped:async(count,context)=>{if(currentInteractionObserverContext(context)){diagnostics.event('browser.evidence',diagnosticContext({operation:'evidence.capture',sessionId:context.sessionId,count,status:'error'}));await audit('browser.interaction.dropped','interaction-capture-bounded');}} + }); + contents.on('did-finish-load',()=>security.interactionObserver?.refresh()); + contents.once('destroyed',()=>security.interactionObserver?.dispose()); const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}}); security.developerToolsGuard=developerTools; contents.setWindowOpenHandler(({url})=>{createNativeTab({url,agentAuthority:tab.security.agentAuthority}).catch(e=>{message=e.message;emit();});return {action:'deny'};}); contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy)||(tab.security.agentAuthority&&!actorDestinationAllowed(url,tab.security.agentAuthority)))event.preventDefault();}); contents.on('will-attach-webview',event=>event.preventDefault()); contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}}); - contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}}); + contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){if(!_inPlace){security.interactionDocumentGeneration++;security.interactionObserver?.refresh();}tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}}); contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit()); contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();}); installClipboardControls(contents); @@ -643,7 +667,7 @@ app.whenReady().then(async()=>{ Menu.setApplicationMenu(null); win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}}); win.removeMenu(); - tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)}); + tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>{tab.security.interactionObserver?.dispose();disposePageClipboardGuard(tab.contents);}}); const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false); clipboardGuard=createClipboardGuard({ getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};}, diff --git a/apps/browser/tab-registry.cjs b/apps/browser/tab-registry.cjs index 1704e4b..01decec 100644 --- a/apps/browser/tab-registry.cjs +++ b/apps/browser/tab-registry.cjs @@ -5,7 +5,7 @@ const {randomUUID}=require('node:crypto'); function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) { if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry'); const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false; - const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;}; + const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.contents.isDestroyed())throw Error('Unknown native tab');return tab;}; const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();}; function create({group=''}={}) { if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached'); @@ -13,31 +13,32 @@ function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{ const view=createView(),contents=view?.webContents; if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view'); const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}}; + Object.defineProperty(tab,'contents',{value:contents,enumerable:false,writable:false,configurable:false}); view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab); if(!activeId)activate(tab.id);return tab; } function activate(id) { const next=requireTab(id);if(id===activeId)return next; - const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);} + const previous=tabs.get(activeId);if(previous){if(!previous.contents.isDestroyed())previous.view.setVisible(false);detach(previous.view);} activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next; } function remove(id) { - const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false); - tabs.delete(id);byContents.delete(tab.view.webContents.id); - if(wasActive){detach(tab.view);activeId=null;activation++;} - try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});} - if(wasActive&&tabs.size)activate(tabs.keys().next().value); + const tab=tabs.get(id);if(!tab)throw Error('Unknown native tab');const wasActive=id===activeId;invalidate(tab);if(!tab.contents.isDestroyed())tab.view.setVisible(false); + tabs.delete(id);byContents.delete(tab.contents.id); + if(wasActive){if(!tab.contents.isDestroyed())detach(tab.view);activeId=null;activation++;} + try{dispose(tab);}finally{if(!tab.contents.isDestroyed())tab.contents.close({waitForBeforeUnload:false});} + if(wasActive&&!closed){const next=[...tabs.values()].find(item=>!item.contents.isDestroyed());if(next)activate(next.id);} } function context(id=activeId,{foreground=true}={}) { - const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused}); + const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.contents,navigationId:tab.navigationId,url:tab.contents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused}); } function current(token,{expectedURL=token?.url}={}) { const tab=token&&tabs.get(token.tabId); - return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation)); + return !!tab&&!paused&&!token.paused&&!tab.contents.isDestroyed()&&tab.contents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.contents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation)); } function quarantine() { paused=true;activation++; - for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();} + for(const tab of tabs.values()){invalidate(tab);if(!tab.contents.isDestroyed()){tab.contents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.contents.closeDevTools?.();}} } function resume(version) { if(!Number.isSafeInteger(version)||version<1||version{},detach=()=>{},dispose=()=>{ fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()], navigation:id=>invalidate(requireTab(id)), close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);}, - summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})}; + summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.contents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.contents.navigationHistory?.canGoBack()||false,canGoForward:tab.contents.navigationHistory?.canGoForward()||false}))})}; } module.exports={createTabRegistry}; diff --git a/apps/server/app.mjs b/apps/server/app.mjs index 2e4e950..3474639 100644 --- a/apps/server/app.mjs +++ b/apps/server/app.mjs @@ -108,8 +108,8 @@ export function createApp(store,config) { store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true}); }); app.post('/api/device/events',device,(req,res)=>{ - const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body); - if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching clipboard lifecycle event'}); + const b=z.object({eventId:z.string().uuid(),kind:z.enum(['browser.interaction','browser.interaction.dropped','dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body); + if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching lifecycle event'}); if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'}); if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'}); if(b.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'}); diff --git a/apps/server/evidence.mjs b/apps/server/evidence.mjs index 7a79767..9ddcc90 100644 --- a/apps/server/evidence.mjs +++ b/apps/server/evidence.mjs @@ -37,11 +37,14 @@ function includesCredential(payload){ function sourceMatches(source,payload,type){ let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;} if(type==='interaction'){ - if(payload.outcome!=='completed'||!payload.sessionId)return false; - if(source.kind==='agent.action'){ + if(!payload.sessionId)return false; + if(source.kind==='browser.interaction'){ + if(payload.captureSource!=='human-observer'||payload.outcome!=='observed'||!['click','submit'].includes(payload.action)||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='observed'||metadata.interaction?.sessionId!==payload.sessionId)return false; + }else if(source.kind==='agent.action'){ + if(payload.outcome!=='completed')return false; if(payload.captureSource!=='agent-adapter'||metadata.agent?.action!=='click'||metadata.agent.outcome!=='completed'||payload.action!=='click'||metadata.agent.sessionId!==payload.sessionId)return false; }else{ - if(payload.captureSource!=='guarded-clipboard'||!['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)||payload.action!==source.kind.split('.')[1]||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='completed'||metadata.interaction?.sessionId!==payload.sessionId)return false; + if(payload.outcome!=='completed'||payload.captureSource!=='guarded-clipboard'||!['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)||payload.action!==source.kind.split('.')[1]||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='completed'||metadata.interaction?.sessionId!==payload.sessionId)return false; } } if(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false; @@ -71,7 +74,7 @@ export function installEvidence(app,store,config,device,admin){ if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads||b.type==='interaction'&&!policy.evidence.captureInteractions)return res.status(403).json({error:'Evidence collection is disabled by current policy'}); const payload=(b.type==='interaction'?InteractionEvidenceSchema:payloads[b.type]).parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'}); const source=db.prepare('SELECT id,kind,metadata FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'}); - const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'}); + const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action','browser.interaction'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'}); if(!sourceMatches(source,payload,b.type))return res.status(400).json({error:'Evidence facts contradict the source event'}); const prior=db.prepare('SELECT * FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior){ let same=false;try{if(prior.key_id===keyId){const priorPlain=decryptStoredEvidence(prior,key);try{same=JSON.stringify(JSON.parse(priorPlain.toString('utf8')))===JSON.stringify(payload);}finally{priorPlain.fill(0);}}}catch{} diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js index f1daa84..9b33322 100644 --- a/apps/server/public/admin.js +++ b/apps/server/public/admin.js @@ -330,7 +330,7 @@ async function reviewHistory(version){ const comparison=await api(`/api/admin/policy/diff?from=${version}&to=${current.currentVersion}`);if(!Array.isArray(comparison.changes)||!Array.isArray(comparison.groupDiff?.changes)||!Array.isArray(comparison.profileDiff?.changes))throw Error('Complete baseline, group and user-profile differences are required before restoration. Refresh and compare again.');if(comparison.truncated||comparison.groupDiff.truncated||comparison.profileDiff.truncated)throw Error('Policy difference exceeds the supported review size. Review the policy through the API before restoration.'); - historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...comparison.groupDiff.changes.map(change=>({...change,path:`/policyGroups${change.path}`})),...comparison.profileDiff.changes.map(change=>({...change,path:`/userProfiles${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths, ${comparison.groupDiff.changes.length} group paths and ${comparison.profileDiff.changes.length} user-profile/category paths changed. Review verified-subject and device assignments, website access, category definitions and developer-tool grants below. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'}); + historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...comparison.groupDiff.changes.map(change=>({...change,path:`/policyGroups${change.path}`})),...comparison.profileDiff.changes.map(change=>({...change,path:`/userProfiles${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths, ${comparison.groupDiff.changes.length} group paths and ${comparison.profileDiff.changes.length} user-profile/category paths changed. Review verified-subject and device assignments, website access, category definitions and developer-tool grants below. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;const grantChanges=[...comparison.changes,...comparison.groupDiff.changes,...comparison.profileDiff.changes].some(change=>/(?:^|\/)agents\/grants(?:\/|$)/.test(change.path)||[change.before,change.after].some(value=>value&&typeof value==='object'&&JSON.stringify(value).includes('"grants"')));if(grantChanges)$('history-review-summary').textContent+=' Agent credential warning: each enrolled browser preserves its highest observed credential generation. Restoring an older generation does not restore invalidated credentials and leaves that grant disabled on browsers that observed a higher generation. To reactivate safely, publish a generation greater than every previously issued generation and pair the agent again locally.'+(independentApprovalRequired?' Independent approval still applies; this checkbox only prepares restoration.':'');$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'}); } diff --git a/apps/server/public/index.html b/apps/server/public/index.html index dafcef9..2403751 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -15,7 +15,7 @@

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

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.

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, completed action and bounded target metadata. Page paths, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Physical user clicks and submissions are not automatically captured by this increment. 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/docs/development-observability.md b/docs/development-observability.md index 7bcfe99..4c769e5 100644 --- a/docs/development-observability.md +++ b/docs/development-observability.md @@ -96,3 +96,15 @@ browser exports stay at 18 across revocation while backend collection remains independent. The earlier aggregate assertion conflated the two services; its failed run is retained rather than presented as successful. Do not retroactively assign the original extra batch to a service without its original payload. + +Foreground gesture development used actual traces to find a second attribution +gap: a queue flusher started by one event delivered the next event under the first +async context. `withCorrelation` now validates each durable record's own IDs and +`audit.deliver` creates a recording delivery child under that source parent. +Malformed IDs clear unrelated context, and current signed sampling/disable still +applies. Real OTLP regression tests and three actual human-event traces prove +distinct parents and complete evidence phase correlation. Trace +`b7783f42197d0cff83957b0ee96e8296` has 17 spans, capture 19.50 ms and send 13.20 ms; +`62e09413b9a42f4a58bd7f99485176ac` and +`545ddac025113e50effed4e9a2ac87ee` each also have 17 spans. Collector rejected writes +were zero. These are local reproduction timings, not throughput guarantees. diff --git a/docs/individual-agent-grants.md b/docs/individual-agent-grants.md index 2f6fbe5..b96e14f 100644 --- a/docs/individual-agent-grants.md +++ b/docs/individual-agent-grants.md @@ -24,4 +24,12 @@ reliable ownership support. Actual exported agent request traces carry named chi phases and link to attributable durable events. The package verifier runs the shipped individual client. A separate backend integration test exercises distinct reviewer publication and history restore; grant-specific native publication UI -coverage remains an open acceptance gate. +coverage is now verified by `scripts/agent-grants-history-admin-smoke.mjs`. +It exercises the actual author and independent-reviewer UI, denied self-approval, +read-only auditor, rotation and draft-only history restoration while preserving +an unrelated unpublished draft. Exported requests have verified parent trace +correlation; screenshots were visually inspected. The history summary warns +that restoring a lower generation cannot revive invalidated credentials: publish +a generation greater than every previously issued value and pair again. + +![Agent history review with credential-generation warning](media/screenshots/admin-agent-history.png) diff --git a/docs/interaction-evidence.md b/docs/interaction-evidence.md index 190efb2..6f52edd 100644 --- a/docs/interaction-evidence.md +++ b/docs/interaction-evidence.md @@ -2,20 +2,25 @@ Administrators separately enable `evidence.enabled`, `captureDownloads` and `captureInteractions` through reviewed signed policy. All are off by default. -Interaction capture currently covers confirmed guarded copy/cut/paste and -completed agent clicks. It is not a recording of every physical user click. +Interaction capture covers confirmed guarded copy/cut/paste, completed agent +clicks and trusted clicks/gesture-associated form events on the active main-frame +page. Human events are marked `observed`, including prevented events; this never +claims transmission or completion of a page's asynchronous effect. Ordinary clipboard audit distinguishes authorization before the effect from completion after the effect. Protected completion metadata contains website origin, opaque session ID, action, structural target tag/role, bounded viewport coordinates and optional byte count. Paths, queries, fragments, field values, document text, selectors, labels and content hashes are excluded. Sensitive -fields do not produce this capture. +fields do not produce this capture. Sensitive forms are excluded as a whole, +including password/file/hidden controls and bounded credential/card markers. +Form observation also requires a recent trusted click or Enter gesture and active +user activation; `requestSubmit()` alone does not establish a human gesture. Agent completion capture is a separate audit phase from RPC completion; two events do not mean two clicks. The native fixture verifies one click effect. The server binds evidence to the accepted device event, action, source, session -and completed outcome. New writes reject raw interaction content. Existing +and the correct observed/completed outcome. New writes reject raw interaction content. Existing encrypted legacy evidence remains readable through restricted audited access. The source event and typed protected payload are persisted in one DPAPI-protected @@ -27,10 +32,22 @@ ordinary audit and separately allowed download evidence. Existing retained records still follow retention, legal-hold and purpose-bound analyst access. Debug traces measure capture, challenge, encryption, send and acknowledgement, -with correlated server decrypt/store phases. These timing spans carry no raw +with correlated server decrypt/store phases. Queue delivery resumes each record's +own trace parent rather than inheriting a different event's flusher context. +Observer install/drain/dispose phases have separate bounded debug spans. +These timing spans carry no raw interaction contents. Evidence retention is independent of diagnostics. -Remaining coverage includes general physical interaction observation, controlled -content capture, all browser convenience clipboard paths and a proven scoped +The private isolated-world observer has no page-visible bridge. It expires after +1.5 seconds without a privileged drain, caps its queue at 16, allows a burst of +20 and replenishes at 10 events per second. Main validates every drained record +and rejects changed documents, activation, policy, ownership and overlays. A +visual reinspection of the same document does not invalidate captured metadata. +Rate/queue losses emit a durable bounded-capture warning and a diagnostic count; +capture is not a complete forensic replay. Subframes, shadow internals, all +keyboard actions and asynchronous effect reconstruction remain outside coverage. + +Remaining coverage includes controlled content capture, +all browser convenience clipboard paths and a proven scoped credential-submission exception. Do not assume this feature is full page replay or blanket password-field authorization. diff --git a/docs/media/screenshots/admin-agent-history.png b/docs/media/screenshots/admin-agent-history.png new file mode 100644 index 0000000..3d350b9 Binary files /dev/null and b/docs/media/screenshots/admin-agent-history.png differ diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index f655ce4..738446d 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -501,3 +501,66 @@ service-attributed native diagnostics tests passes 522 tests. The actual native browser proves diagnostic export stops on revocation; Windows build and packaged source/runtime verification pass. Production completeness and the broader original open requirements are not implied. + +## Foreground gesture evidence and reviewed agent history + +The browser now observes trusted foreground main-document clicks and +gesture-associated submit events in a private isolated world. Signed evidence +policy controls collection; sensitive forms, synthetic events and raw values +are excluded. New server event/source validation accepts only matching observed +click/submit facts. ECS explicitly reports an unknown outcome rather than claiming +successful transmission. Bounded collection and coverage limitations are described +in `docs/interaction-evidence.md`; this is not full replay or content capture. + +Actual native proof verifies seven protected records, seven audited analyst +reveals, synthetic `click`/`dispatchEvent`/`requestSubmit` exclusion, password-form +exclusion, independent reviewed disable and continued scanned download evidence. +First failures identified the missing server event enum; subsequent trace checks +identified shared flusher context attributing two queued records to one source. +Both are fixed and negative/real-OTLP tests added. Each queued record now resumes +its own validated trace parent. All three actual human events have 17 correlated +spans each (`artifacts/human-interaction-trace-proof.json`). A visual DOM +reinspection does not imply another document; true document, activation, policy, +URL and native ownership changes still invalidate observation. + +The native admin workflow now verifies grant creation, separate review, +self-approval refusal, auditor read-only controls, generation rotation and history +restoration without discarding an unrelated draft. The history view warns that +old credential generations cannot be resurrected and require a higher generation +plus re-pairing. Screenshots were visually inspected; real backend OTLP parents +verified (`artifacts/agent-grants-history-admin-proof.json`). + +The separately evidenced shutdown cleanup error is fixed using an immutable, +non-enumerable native WebContents reference; destroyed views are removable and +closing does not reactivate remaining tabs. This does not establish the cause of +the earlier startup timeout. The current suite passes 546 tests. Production key +custody, signed installer/update distribution, worker/session ownership, full +secured agent capabilities, content capture, external customer integrations and +independent assessment remain open. The full objective is not complete. + +Final Windows build and packaged-source/runtime verification pass, including +the new observer, contained scanner, immutable upload leases, offline semantic +inference and shipped protected agent clients. Syntax and actual native tab/DLP +tests pass (`artifacts/human-windows-build.log`, `human-package-proof.log`, +`human-final-syntax.log`, `human-native-tabs.log`). Authenticode is still +`NotSigned`; an unpacked build is not a signed production installer. + +### Next priorities against the original requirements + +1. Complete controlled agent capabilities: history/session restoration, window + management, safe visual capture, vault/file operations and administrative + actions need dedicated scopes, principal ownership, central disable, DLP and + durable attribution. Do not supply a generic JavaScript/DevTools bypass. +2. Support legitimate background workers in individual mode with provable native + ownership/session isolation; ownerless requests currently remain blocked. +3. Prove legitimate credential submission without exempting arbitrary password + controls or bypassing inspection of other form fields and changed targets. +4. Expand protected evidence to remaining interactions and controlled content + workflows with collection-gap visibility and purpose-bound analyst access. + Existing observed gestures do not establish full reconstruction. +5. Verify streaming/ranged/native upload paths and main/network-service crash + semantics, then production enrollment/device key custody/rotation, signed + installer/update delivery and real customer SSO/ICAP/SIEM/HA integrations. + These require independent deployment evidence, not only mocked local tests. + +Mac/Linux/mobile remain explicitly deferred by the user's Windows-first scope. diff --git a/packages/core/evidence-payload.cjs b/packages/core/evidence-payload.cjs index c8e0848..43ac388 100644 --- a/packages/core/evidence-payload.cjs +++ b/packages/core/evidence-payload.cjs @@ -2,7 +2,11 @@ const {z}=require('zod'); const InteractionEvidenceSchema=z.object({ pageUrl:z.string().max(4096).refine(value=>{try{const u=new URL(value);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URL must exclude credentials, query and fragment'), - action:z.enum(['click','copy','cut','paste','submit']),sessionId:z.string().uuid(),outcome:z.literal('completed'),captureSource:z.enum(['guarded-clipboard','agent-adapter']),bytes:z.number().int().min(0).max(16777216).optional(), + action:z.enum(['click','copy','cut','paste','submit']),sessionId:z.string().uuid(),outcome:z.enum(['completed','observed']),captureSource:z.enum(['guarded-clipboard','agent-adapter','human-observer']),bytes:z.number().int().min(0).max(16777216).optional(), target:z.object({tag:z.enum(['A','BUTTON','INPUT','TEXTAREA','SELECT','TEXT','OTHER','DIV','SPAN','P','BODY','UNKNOWN']),role:z.enum(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option']).optional(),x:z.number().finite().min(0).max(10000000).optional(),y:z.number().finite().min(0).max(10000000).optional()}).strict().optional(),sensitiveField:z.literal(false) -}).strict(); +}).strict().superRefine((value,context)=>{ + const human=value.captureSource==='human-observer'; + if(human){try{if(value.pageUrl!==new URL(value.pageUrl).origin+'/')context.addIssue({code:'custom',message:'Human metadata must contain origin only'});}catch{context.addIssue({code:'custom',message:'Invalid human metadata origin'});}} + if(human?(value.outcome!=='observed'||!['click','submit'].includes(value.action)||value.bytes!==undefined):(value.outcome!=='completed'||value.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(value.action)||value.captureSource==='agent-adapter'&&value.action!=='click'))context.addIssue({code:'custom',message:'Interaction source, action and outcome must agree'}); +}); module.exports={InteractionEvidenceSchema}; diff --git a/packages/core/interaction-facts.cjs b/packages/core/interaction-facts.cjs index aee644a..93a9e5f 100644 --- a/packages/core/interaction-facts.cjs +++ b/packages/core/interaction-facts.cjs @@ -1,10 +1,11 @@ 'use strict'; const {z}=require('zod'); -const InteractionFactsSchema=z.object({action:z.enum(['copy','cut','paste','click','submit']),outcome:z.enum(['authorized','completed','denied']),sessionId:z.string().uuid()}).strict(); +const InteractionFactsSchema=z.object({action:z.enum(['copy','cut','paste','click','submit']),outcome:z.enum(['authorized','completed','denied','observed']),sessionId:z.string().uuid()}).strict(); function interactionFacts(value){return InteractionFactsSchema.parse(value);} function interactionFactsForEvent(kind,value){ const parsed=InteractionFactsSchema.safeParse(value);if(!parsed.success)return null; const facts=parsed.data; + if(kind==='browser.interaction')return facts.outcome==='observed'&&['click','submit'].includes(facts.action)?facts:null; if(kind==='clipboard.block')return facts.outcome==='denied'&&['copy','cut','paste'].includes(facts.action)?facts:null; return ['clipboard.copy','clipboard.cut','clipboard.paste'].includes(kind)&&facts.action===kind.split('.')[1]&&['authorized','completed'].includes(facts.outcome)?facts:null; } diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs index cff80f5..e5a4265 100644 --- a/packages/core/siem.mjs +++ b/packages/core/siem.mjs @@ -12,7 +12,7 @@ export function toECS(event,installationId='safetynet') { '@timestamp':event.time,ecs:{version:'8.17.0'}, event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security', action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'], - type:[alert?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})}, + type:[alert||interaction?.outcome==='observed'?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:interaction?.outcome==='observed'?'unknown':failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})}, observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'}, ...(event.device_id?{host:{id:event.device_id}}:{}), ...(m.actor?{user:{id:m.actor}}:{}),...(m.host?{destination:{domain:m.host}}:{}), diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs new file mode 100644 index 0000000..8d25fb3 --- /dev/null +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -0,0 +1,19 @@ +'use strict'; +const {app,BrowserWindow,session}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs');app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);let window;const deadline=setTimeout(()=>app.exit(1),55000);const evaluate=s=>window.webContents.executeJavaScript(s,true); +async function waitFor(s){for(let n=0;n<200;n++){if(await evaluate(s))return;await new Promise(r=>setTimeout(r,60));}throw Error('UI state timeout: '+s+' | '+await evaluate(`document.getElementById('status').textContent`));} +async function identity(value){await session.defaultSession.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value,httpOnly:true,sameSite:'strict'});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('reviews-status').textContent.includes('loaded')`);} +async function selectReview(){await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`);} +async function approve(version){assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v${version}'`);} +async function capture(file,section){await evaluate(`document.getElementById('${section}').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);await new Promise(r=>setTimeout(r,250));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(file,(await window.webContents.capturePage()).toPNG());} +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=[];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');proofTraceIds.push({traceId,parentSpanId:spanId});callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});}); + await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').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 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'].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'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); + await identity('grant-auditor');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); + fs.writeFileSync('artifacts/agent-grants-admin-request-trace-ids.json',JSON.stringify(proofTraceIds));console.log('GRANT_ADMIN_OK');clearTimeout(deadline);window.destroy();app.exit(0); +}).catch(error=>{console.error('GRANT_ADMIN_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);}); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs new file mode 100644 index 0000000..d7a8548 --- /dev/null +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,rmSync,writeFileSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep,basename} from 'node:path'; +import {spawn} from 'node:child_process'; +import {digest,openStore} from '../apps/server/store.mjs'; +import {createApp} from '../apps/server/app.mjs'; +import {verifyPolicy} from '../packages/core/policy.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +process.env.OTEL_EXPORTER_OTLP_ENDPOINT='http://127.0.0.1:4318'; +const health=await fetch('http://127.0.0.1:4318/health').then(r=>r.json());assert.equal(health.rejectedWrites,0); +const sdk=startTelemetry(),require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-agent-grant-admin-'));let store,server,child; +try { + store=openStore(join(directory,'management'));for(const [credential,role,sub] of [['grant-author','admin','grant-author'],['grant-reviewer','admin','grant-reviewer'],['grant-auditor','auditor','grant-auditor']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+3600000,JSON.stringify({sub,role})); + const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};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_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE; + 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,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual'); + 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=[];for(const item of traces){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);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,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); +} 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/interaction-evidence-electron.cjs b/scripts/interaction-evidence-electron.cjs index 8eb9d5f..f303039 100644 --- a/scripts/interaction-evidence-electron.cjs +++ b/scripts/interaction-evidence-electron.cjs @@ -23,12 +23,18 @@ app.whenReady().then(async()=>{try{ const sessionId=(await state()).activeTabId,requestFile=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'interaction-request.json'); fs.writeFileSync(requestFile,JSON.stringify({action:'click',args:{sessionId,selector:'#public-button'}})); const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.data.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});assert.equal(exit,0);assert.equal(JSON.parse(output).result.completed,true);assert.equal(await page.executeJavaScript("document.getElementById('public-button').dataset.clicked"),'1');await until(async()=>(await records()).length===4); + stage='human observer installation';await until(()=>page.executeJavaScriptInIsolatedWorld(998,[{code:"!!globalThis.__safetynetHumanInteraction"}],false));await page.executeJavaScript("window.__fixtureTrustedClick=false;window.addEventListener('click',e=>{window.__fixtureTrustedClick=e.isTrusted},true)"); + stage='synthetic human exclusion';await page.executeJavaScript("document.getElementById('public-button').click();document.getElementById('public-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));document.getElementById('public-form').requestSubmit()");await wait(700);assert.equal((await records()).length,4); + async function physicalClick(id){const point=await page.executeJavaScript(`(()=>{const r=document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();return {x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)}})()`);page.focus();page.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point});page.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point});await wait(900);} + stage='physical click';await physicalClick('public-button');assert.equal(await page.executeJavaScript('window.__fixtureTrustedClick'),true);assert.equal(await page.executeJavaScript("document.getElementById('public-button').dataset.clicked"),'3');await until(async()=>(await records()).length===5); + stage='physical submit';await physicalClick('submit-button');assert.equal(await page.executeJavaScript("document.getElementById('public-form').dataset.submitted"),'3');await until(async()=>(await records()).length===7); + stage='private form exclusion';await physicalClick('private-submit');await wait(500);assert.equal((await records()).length,7); const recorded=await records(),payloads=[]; stage='analyst reveal';for(const e of recorded){const response=await fetch(base+`/api/admin/evidence/${e.metadata.evidenceId}?reason=investigation`,{headers});assert.equal(response.status,200);const item=await response.json();payloads.push(item.payload);} - assert.deepEqual(payloads.map(p=>p.action).sort(),['click','copy','cut','paste']);assert.ok(payloads.every(p=>p.sessionId===sessionId&&p.outcome==='completed'&&p.sensitiveField===false));assert.ok(payloads.some(p=>p.action==='click'&&p.captureSource==='agent-adapter'&&p.target?.tag==='BUTTON')); + assert.deepEqual(payloads.map(p=>p.action).sort(),['click','click','click','copy','cut','paste','submit']);assert.ok(payloads.every(p=>p.sessionId===sessionId&&p.sensitiveField===false));assert.equal(payloads.filter(p=>p.captureSource==='human-observer'&&p.outcome==='observed').length,3);assert.ok(payloads.every(p=>p.pageUrl===new URL(p.pageUrl).origin+'/'));assert.ok(payloads.some(p=>p.action==='click'&&p.captureSource==='agent-adapter'&&p.target?.tag==='BUTTON')); const serialized=JSON.stringify(payloads),eventsBefore=(await overview()).events; - for(const marker of ['Synthetic public selection','Synthetic pasted text','Synthetic control label','Synthetic password attempt','never-export-this','#public-button','#private-input']){assert.equal(serialized.includes(marker),false);assert.equal(JSON.stringify(eventsBefore).includes(marker),false);} - assert.ok(eventsBefore.filter(e=>e.kind==='evidence.accessed').length>=4); + for(const marker of ['Synthetic public selection','Synthetic pasted text','Synthetic control label','Synthetic password attempt','never-export-this','Synthetic private form field','#public-button','#private-input']){assert.equal(serialized.includes(marker),false);assert.equal(JSON.stringify(eventsBefore).includes(marker),false);} + assert.equal(eventsBefore.filter(e=>e.kind==='evidence.accessed').length,7); stage='admin policy screenshot';const adminWindow=new BrowserWindow({width:1440,height:1000,show:false,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}}); await adminWindow.webContents.session.cookies.set({url:base,name:'sn_session',value:process.env.SAFETYNET_SMOKE_REVIEWER,httpOnly:true,sameSite:'strict'});await adminWindow.loadURL(base+'/'); await until(()=>adminWindow.webContents.executeJavaScript("(()=>{const e=document.getElementById('evidence-policy-interactions');return !!e&&e.checked&&!e.disabled})()")); @@ -37,9 +43,9 @@ app.whenReady().then(async()=>{try{ stage='independent policy review';const policy=(await overview()).policy;policy.version++;policy.evidence.captureInteractions=false; const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(policy)});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()=>(await state()).policyVersion===policy.version&&(await state()).ready); - stage='disabled interaction';await page.executeJavaScript("document.getElementById('public-input').focus()");await clipboard.writeText('Synthetic post-disable text');await key('V');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'Synthetic post-disable text');await wait(600);assert.equal((await records()).length,4); + stage='disabled interaction';await page.executeJavaScript("document.getElementById('public-input').focus()");await clipboard.writeText('Synthetic post-disable text');await key('V');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'Synthetic post-disable text');await wait(600);await physicalClick('public-button');assert.equal((await records()).length,7); stage='download independently enabled';dialog.showSaveDialog=async()=>({canceled:false,filePath:path.join(process.env.SAFETYNET_SMOKE_USERDATA,'safe.bin')});await page.executeJavaScript("document.getElementById('safe-download').click()");await until(async()=>{const s=await state();return s.ready&&s.message==='Download scanned and saved.';});assert.deepEqual(fs.readFileSync(path.join(process.env.SAFETYNET_SMOKE_USERDATA,'safe.bin')),Buffer.from([0,1,2,3,255]));await until(async()=>((await overview()).events||[]).some(e=>e.kind==='evidence.recorded'&&e.metadata.evidenceType==='download')); - const after=(await overview()).events,sources=recorded.map(e=>after.find(source=>source.id===e.metadata.sourceEventId));assert.ok(sources.every(Boolean));assert.ok(sources.every(e=>e.metadata.traceId)); - fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-proof.json'),JSON.stringify({actualElectron:true,nativeCopyPasteCut:true,agentClickExactlyOnce:true,passwordControlExcluded:true,noRawContentLabelsSelectors:true,completedActionsBoundToSession:true,analystAccessAudited:true,independentSignedDisable:true,actualAdminControlsScreenshot:true,disabledInteractionLeavesDownloadCapture:true,eventIds:sources.map(e=>e.id),traceIds:sources.map(e=>e.metadata.traceId)},null,2)); + const after=(await overview()).events,sources=recorded.map(e=>after.find(source=>source.id===e.metadata.sourceEventId));assert.ok(sources.every(Boolean));assert.ok(sources.every(e=>e.metadata.traceId));sources.sort((a,b)=>Number(b.kind==='agent.action')-Number(a.kind==='agent.action'));assert.equal(sources.filter(e=>e.kind==='browser.interaction').length,3); + fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-proof.json'),JSON.stringify({actualElectron:true,physicalTrustedClickAndSubmit:true,syntheticHumanEventsExcluded:true,passwordFormsExcluded:true,humanObservationNotTransmission:true,nativeCopyPasteCut:true,agentClickExactlyOnce:true,passwordControlExcluded:true,noRawContentLabelsSelectors:true,completedActionsBoundToSession:true,analystAccessAudited:true,independentSignedDisable:true,actualAdminControlsScreenshot:true,disabledInteractionLeavesDownloadCapture:true,eventIds:sources.map(e=>e.id),traceIds:sources.map(e=>e.metadata.traceId),humanTraceIds:sources.filter(e=>e.kind==='browser.interaction').map(e=>e.metadata.traceId),humanEventIds:sources.filter(e=>e.kind==='browser.interaction').map(e=>e.id)},null,2)); console.log('Native protected interaction evidence proof passed.');await clipboard.writeText(originalClipboard);require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0); - }catch(error){if(originalClipboard!==undefined)await clipboard.writeText(originalClipboard);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message},null,2));console.error(`Interaction evidence fixture failed at ${stage}: ${error.name}`);app.exit(1);}}); + }catch(error){if(originalClipboard!==undefined)await clipboard.writeText(originalClipboard);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);let checkpoint={};try{if(page&&!page.isDestroyed())checkpoint.observer=await page.executeJavaScriptInIsolatedWorld(998,[{code:"(()=>{const s=globalThis.__safetynetHumanInteraction;return {installed:!!s,pending:s?.queue?.length||0,dropped:s?.dropped||0}})()"}],false);const queuePath=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'audit-queue.bin');if(fs.existsSync(queuePath)){const queue=JSON.parse(require('electron').safeStorage.decryptString(fs.readFileSync(queuePath)));checkpoint.pendingHumanSources=queue.filter(e=>e.kind==='browser.interaction').slice(0,16).map(({eventId,traceId,spanId,policyVersion})=>({eventId,traceId,spanId,policyVersion}));}}catch{}fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,checkpoint},null,2));console.error(`Interaction evidence fixture failed at ${stage}: ${error.name}`);app.exit(1);}}); diff --git a/scripts/interaction-evidence-smoke.mjs b/scripts/interaction-evidence-smoke.mjs index 3ddd761..41e061e 100644 --- a/scripts/interaction-evidence-smoke.mjs +++ b/scripts/interaction-evidence-smoke.mjs @@ -14,7 +14,7 @@ const smokeTelemetry=startTelemetry(); const temporary=mkdtempSync(join(tmpdir(),'safetynet-smoke-'));mkdirSync('artifacts',{recursive:true}); const evidenceKeyFile=join(temporary,'evidence.key');writeFileSync(evidenceKeyFile,randomBytes(32),{mode:0o600}); if(process.platform==='win32')execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_EVIDENCE_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:evidenceKeyFile},stdio:'pipe'}); -const sink=createServer((req,res)=>{if(req.url==='/download-safe'){res.setHeader('Content-Type','application/octet-stream');res.setHeader('Content-Disposition','attachment; filename="safe.bin"');res.end(Buffer.from([0,1,2,3,255]));return;}res.setHeader('Content-Type','text/html');res.end('Interaction fixture
Synthetic public selection
Download');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); +const sink=createServer((req,res)=>{if(req.url==='/download-safe'){res.setHeader('Content-Type','application/octet-stream');res.setHeader('Content-Disposition','attachment; filename="safe.bin"');res.end(Buffer.from([0,1,2,3,255]));return;}res.setHeader('Content-Type','text/html');res.end('Interaction fixture
Synthetic public selection
Download');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); const inspectorToken=randomBytes(32).toString('base64url'); const inspectorProcess=spawn(resolve('.runtime/windows/python/python.exe'),['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}}); @@ -48,6 +48,15 @@ if(exitCode===0&&process.env.SAFETYNET_TEST_OTLP_ENDPOINT){ const trace=await fetch(endpoint+'/api/trace/'+proof.traceIds[0]).then(r=>r.json()),spans=trace.spans||[],root=spans.find(s=>s.operation==='agent.request'); if(!root||!spans.some(s=>s.operation==='agent.dispatch'&&s.parentSpanId===root.spanId)||!['evidence.challenge','evidence.decrypt','evidence.store','evidence.encrypt'].every(name=>spans.some(s=>s.name===name&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Actual interaction evidence trace correlation missing'); const browserStages=['evidence.capture','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'];if(!browserStages.every(operation=>spans.some(s=>s.name==='browser.evidence'&&s.operation===operation&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Browser evidence stages missing or uncorrelated'); + const humanTraceProof=[]; + for(let i=0;ir.json()),humanSpans=item.spans||[]; + const capture=humanSpans.find(span=>span.name==='browser.evidence'&&span.operation==='evidence.capture'); + if(!capture||!humanSpans.some(span=>span.name==='evidence.store')||!['evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'].every(operation=>humanSpans.some(span=>span.name==='browser.evidence'&&span.operation===operation))){writeFileSync(resolve('artifacts/human-interaction-trace-error.json'),JSON.stringify({traceId:item.traceId,eventId:proof.humanEventIds[i],spans:humanSpans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))},null,2));throw Error('Physical human source evidence trace phases missing');} + if(!humanSpans.filter(span=>span.parentSpanId).every(span=>humanSpans.some(parent=>parent.spanId===span.parentSpanId)))throw Error('Physical human evidence trace parent missing'); + humanTraceProof.push({traceId:item.traceId,eventId:proof.humanEventIds[i],spanCount:humanSpans.length,captureMs:capture.durationMs,parentCorrelation:true,phases:humanSpans.filter(span=>span.name==='browser.evidence').map(({operation,durationMs})=>({operation,durationMs}))}); + } + writeFileSync(resolve('artifacts/human-interaction-trace-proof.json'),JSON.stringify({humanTraceProof,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2)); writeFileSync(resolve('artifacts/interaction-evidence-trace-proof.json'),JSON.stringify({traceId:trace.traceId,linkedNativeEventId:proof.eventIds[0],spanCount:spans.length,agentRootMs:root.durationMs,browserEvidenceStages:spans.filter(s=>s.name==='browser.evidence').map(({operation,durationMs,parentSpanId})=>({operation,durationMs,parentSpanId})),protectedEvidenceStages:spans.filter(s=>s.name.startsWith('evidence.')).map(({name,durationMs,parentSpanId})=>({name,durationMs,parentSpanId})),parentCorrelation:true,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2)); } process.exit(exitCode); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index daf3c2a..38697b2 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`); assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale'); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale'); -for(const file of ['packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); +for(const file of ['apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`); for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`); diff --git a/tests/admin-history-profile-review.test.mjs b/tests/admin-history-profile-review.test.mjs index ab8e915..601af6f 100644 --- a/tests/admin-history-profile-review.test.mjs +++ b/tests/admin-history-profile-review.test.mjs @@ -22,3 +22,8 @@ for(const part of ['baseline','group','profile'])test(`truncated ${part} diff ne }); test('missing user-profile diff fails closed rather than treating it as unchanged',async()=>{const comparison=empty();delete comparison.profileDiff;const f=fixture(comparison);await assert.rejects(f.review(),/Complete baseline, group and user-profile/);assert.equal(f.get('history-review').hidden,true);assert.equal(vm.runInContext('historyReview',f.context),null);}); test('profile values are rendered as literal text and read-only identities cannot approve',async()=>{const comparison=empty();comparison.profileDiff.changes=[{path:'/profiles/0/name',kind:'changed',before:'',after:'Current'}];const f=fixture(comparison,['policy.read']);await f.review();assert.match(f.get('history-diff-list').children[0].children[1].textContent,//);assert.equal(f.get('history-approve').disabled,true);assert.equal(f.get('history-restore').disabled,true);}); + +for(const [part,path] of [['baseline','/agents/grants'],['group','/0/policy/agents/grants'],['profile','/profiles/0/policy/agents/grants']])test(`grant ${part} restoration explains persistent generation floors before confirmation`,async()=>{ + const comparison=empty(),change={path,kind:'changed',before:[{id:'research-agent',generation:1}],after:[{id:'research-agent',generation:2}]};(part==='baseline'?comparison.changes:comparison[`${part}Diff`].changes).push(change);const f=fixture(comparison);await f.review();assert.match(f.get('history-review-summary').textContent,/highest observed credential generation/);assert.match(f.get('history-review-summary').textContent,/does not restore invalidated credentials/);assert.match(f.get('history-review-summary').textContent,/greater than every previously issued generation/);assert.equal(f.get('history-restore').disabled,true); +}); +test('unrelated restoration does not imply a credential-generation change',async()=>{const comparison=empty();comparison.changes=[{path:'/brand/name',kind:'changed',before:'Previous',after:'Current'}];const f=fixture(comparison);await f.review();assert.doesNotMatch(f.get('history-review-summary').textContent,/Agent credential warning/);}); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs index 0d83676..f0c164e 100644 --- a/tests/diagnostics.test.mjs +++ b/tests/diagnostics.test.mjs @@ -52,3 +52,30 @@ test('export failure totals survive disable and reconfiguration without resendin diagnostics.configure(config(endpoint));await diagnostics.span('browser.request',{},async()=>{calls++;});await diagnostics.flush(); assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,failed);assert.equal(calls,3);assert.equal(spans(requests).length,1); }); + + +test('one asynchronous audit flusher resumes each persisted source parent in real OTLP', {timeout:15000},async t=>{ + const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const rows=[];let calls=0; + await diagnostics.span('browser.clipboard',{operation:'copy'},async()=>rows.push({...diagnostics.correlation(),secret:'PERSISTED_SECRET'})); + await diagnostics.span('browser.agent',{operation:'click'},async()=>rows.push({...diagnostics.correlation()})); + // The flusher retains an unrelated span; neither delivery may adopt it. + await diagnostics.span('browser.management',{operation:'sync'},async()=>{ + for(const row of rows)await diagnostics.withCorrelation(row,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{ + calls++;await delay(2);await diagnostics.span('browser.request',{operation:'request'},async()=>{}); + })); + });await diagnostics.flush();const output=spans(requests),deliveries=output.filter(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue==='audit.deliver')); + assert.equal(calls,2);assert.equal(deliveries.length,2);for(const row of rows){const delivery=deliveries.find(s=>s.traceId===row.traceId);assert.ok(delivery);assert.equal(delivery.parentSpanId,row.spanId);assert.ok(output.some(s=>s.name==='browser.request'&&s.traceId===row.traceId&&s.parentSpanId===delivery.spanId));} + assert.equal(JSON.stringify(requests).includes('PERSISTED_SECRET'),false);assert.equal(output.length,7); +}); + +test('invalid persisted IDs clear unrelated parents without getters, retries or disabled exports',{timeout:15000},async t=>{ + const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const good={traceId:'123456789abcdef0123456789abcdef0',spanId:'123456789abcdef0'};let calls=0,getters=0; + const accessor={spanId:good.spanId};Object.defineProperty(accessor,'traceId',{get(){getters++;throw Error('Never invoke');}}); + const invalid=[null,[],accessor,{...good,traceId:'0'.repeat(32)},{...good,spanId:'0'.repeat(16)},{...good,traceId:good.traceId.toUpperCase()},{...good,traceId:good.traceId+'x'},Object.create(good),new Proxy({},{ownKeys(){throw Error('Refuse metadata');}})]; + let unrelated;await diagnostics.span('browser.agent',{operation:'click'},async()=>{ + unrelated=diagnostics.correlation();for(const ids of invalid)await diagnostics.withCorrelation(ids,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;})); + });await diagnostics.flush();assert.equal(getters,0);assert.equal(calls,invalid.length);for(const item of spans(requests).filter(s=>s.name==='browser.management')){assert.notEqual(item.traceId,unrelated.traceId);assert.ok(!item.parentSpanId);} + const failure=Error('Operation failed');assert.throws(()=>diagnostics.withCorrelation(good,()=>{calls++;throw failure;}),error=>error===failure); + const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count); + diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3); +}); diff --git a/tests/evidence.test.mjs b/tests/evidence.test.mjs index b440638..54c2740 100644 --- a/tests/evidence.test.mjs +++ b/tests/evidence.test.mjs @@ -7,6 +7,18 @@ function protectFixtureKey(file){ async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});return {store,api,id,eventId,config};} 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(); + 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); + for(const outcome of ['completed','authorized','denied'])assert.equal((await api('/api/device/events','POST',{eventId:randomUUID(),kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome}})).status,400); + for(const extra of [{action:action==='click'?'submit':'click'},{outcome:'completed'},{sessionId:randomUUID()},{captureSource:'agent-adapter'},{bytes:1},{content:'PRIVATE_BODY'}])assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:{...payload,...extra}})).status,400); + const accepted=await api('/api/device/evidence','POST',{eventId,type:'interaction',payload});assert.equal(accepted.status,201); + assert.deepEqual((await api(`/api/admin/evidence/${accepted.body.id}?reason=investigation`,'GET',undefined,'analyst')).body.payload,payload); + } +}); + test('interaction collection is independent and binds completed action, session and capture source',async t=>{ const {api,store,id,eventId}=await fixture(t),body=evidence(eventId),p=store.get('policy'); p.evidence.captureInteractions=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403); diff --git a/tests/human-interaction-integration.test.mjs b/tests/human-interaction-integration.test.mjs new file mode 100644 index 0000000..e145730 --- /dev/null +++ b/tests/human-interaction-integration.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'); +function fixture(){ + const contents={isDestroyed:()=>false,getURL:()=> 'https://example.test/page'},tab={id:'tab-1',contents,startPage:false,security:{interactionDocumentGeneration:2}},live={navigationId:8,activation:4,paused:false},policy={version:3,expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}}; + const c=vm.createContext({ready:true,policy,activeTab:()=>tab,tabs:{context:()=>({...live})},tabContext:()=>({...live}),currentTabContext:()=>true,overviewPanel:false,toolsOpen:false}); + vm.runInContext(source.slice(source.indexOf('function interactionObserverContext(tab)'),source.indexOf('function installSiteHooks(tab)')),c); + return {c,tab,live,contents,context:c.interactionObserverContext(tab)}; +} +test('human observation survives visual DOM revisions but rejects real document or activation changes',()=>{ + const f=fixture();assert.equal(f.c.currentInteractionObserverContext(f.context),true);f.live.navigationId++;assert.equal(f.c.currentInteractionObserverContext(f.context),true,'Visual reinspection does not imply another document');f.tab.security.interactionDocumentGeneration++;assert.equal(f.c.currentInteractionObserverContext(f.context),false);f.tab.security.interactionDocumentGeneration--;f.live.activation++;assert.equal(f.c.currentInteractionObserverContext(f.context),false); +}); +test('human observation rejects revocation, disabled capture, expiry, overlays and changed native contents',()=>{ + for(const mutate of [f=>f.c.ready=false,f=>f.c.policy.evidence.captureInteractions=false,f=>f.c.policy.version++,f=>f.c.policy.expiresAt=new Date(0).toISOString(),f=>f.c.toolsOpen=true,f=>f.c.overviewPanel=true,f=>f.tab.startPage=true,f=>f.tab.contents={...f.contents},f=>f.contents.getURL=()=> 'https://example.test/other',f=>f.live.paused=true]){const f=fixture();mutate(f);assert.equal(f.c.currentInteractionObserverContext(f.context),false);} +}); diff --git a/tests/human-interaction.test.mjs b/tests/human-interaction.test.mjs new file mode 100644 index 0000000..5a12d03 --- /dev/null +++ b/tests/human-interaction.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {createRequire} from 'node:module'; +const {humanInteractionScript}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs'); +const token='a'.repeat(32),url='https://intranet.example/path?private=1'; +function fixture(){ + let time=1000;const listeners=new Map();const win={addEventListener:(type,fn)=>listeners.set(type,fn),removeEventListener:(type,fn)=>{if(listeners.get(type)===fn)listeners.delete(type);}};win.top=win; + const context=vm.createContext({window:win,location:{href:url},innerWidth:1000,innerHeight:800,navigator:{userActivation:{isActive:true}},Date:{now:()=>time}}); + const run=mode=>vm.runInContext(humanInteractionScript(mode,{url,token}),context); + const target=(tag='BUTTON',attrs={})=>({nodeType:1,tagName:tag,isConnected:true,parentElement:null,type:'button',hasAttribute:key=>Object.hasOwn(attrs,key),getAttribute:key=>attrs[key]||null,closest:()=>null}); + const emit=(type,element=target(),extra={})=>listeners.get(type)?.({type,target:element,isTrusted:true,clientX:12.2,clientY:31.8,...extra}); + return {run,target,emit,listeners,context,advance:ms=>{time+=ms;}}; +} +test('trusted observation exports structural metadata only, rejects synthetic events',()=>{ + const f=fixture();assert.equal(f.run('install').installed,true); + f.emit('click',f.target('BUTTON',{'aria-label':'Confidential deal','data-value':'sensitive'}),{isTrusted:false}); + assert.equal(f.run('drain').events.length,0); + f.emit('click',f.target('BUTTON',{'aria-label':'ordinary button','id':'id-private','data-value':'sensitive'})); + const result=JSON.parse(JSON.stringify(f.run('drain'))); + assert.deepEqual(result.events,[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON',x:12,y:32}}]); + assert.equal(JSON.stringify(result).includes('id-private'),false); +}); +test('password, credential ancestors and sensitive forms never contribute metadata',()=>{ + const f=fixture();f.run('install');const password=f.target('INPUT');password.type='password'; + f.emit('click',password);const child=f.target();child.parentElement=f.target('DIV',{'data-sensitive':''});f.emit('click',child); + const form=f.target('FORM');form.elements=[password];f.emit('submit',form);const button=f.target();button.form=form;f.emit('click',button); + assert.equal(f.run('drain').events.length,0); +}); +test('submit is observed, not evidence of transmission, and carries no fields',()=>{ + const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[f.target('INPUT')];form.elements[0].value='company confidential'; + f.emit('keydown',form.elements[0],{key:'Enter'}); + f.emit('submit',form,{defaultPrevented:true}); + assert.deepEqual(JSON.parse(JSON.stringify(f.run('drain').events)),[{action:'submit',outcome:'observed',sensitiveField:false,target:{tag:'OTHER'}}]); +}); +test('queue and rate are bounded, lease expiry and navigation prevent new capture',()=>{ + const f=fixture();f.run('install');for(let i=0;i<1000;i++)f.emit('click'); + let result=f.run('drain');assert.equal(result.events.length,16);assert.equal(result.dropped,984); + for(let i=0;i<100;i++)f.emit('click');assert.equal(f.run('drain').events.length,4); + f.advance(1600);f.emit('click');assert.equal(f.run('drain').events.length,0); + f.context.location.href='https://other.example/';f.emit('click');assert.equal(f.run('drain'),null); +}); +test('replacement and dispose remove listeners and pending metadata',()=>{ + const f=fixture();f.run('install');f.emit('click');f.run('install');assert.equal(f.run('drain').events.length,0); + f.run('dispose');assert.equal(f.listeners.size,0);assert.equal(f.run('drain'),null); + assert.throws(()=>humanInteractionScript('install',{url,token:'untrusted'})); +}); +const {createHumanInteractionObserver}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs'); +function controllerFixture(){ + const dom=fixture();const contents={isDestroyed:()=>false};let context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:contents};const seen=[]; + const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:s=>context.ready&&context.enabled&&s.token===context.token&&s.policyVersion===context.policyVersion&&s.foreground===context.foreground,executeScript:async(_contents,code)=>vm.runInContext(code,dom.context),onObserved:async event=>seen.push(event)}); + return {dom,observer,seen,set:update=>{context={...context,...update};}}; +} +test('controller binds current navigation and policy and drops queued stale metadata',async()=>{ + const f=controllerFixture();try{ + await f.observer.poll();f.dom.emit('click');await f.observer.poll();assert.equal(f.seen.length,1); + f.dom.emit('click');f.set({token:'nav-2'});await f.observer.poll();await f.observer.poll();assert.equal(f.seen.length,1); + f.dom.emit('click');f.set({enabled:false});await f.observer.poll();assert.equal(f.dom.listeners.size,0);assert.equal(f.seen.length,1); + }finally{f.observer.dispose();} +}); +test('refresh and dispose invalidate in-flight batches without replay',async()=>{ + let resolve;const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};const seen=[];let calls=0; + const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};return new Promise(r=>{resolve=r;});},onObserved:async value=>seen.push(value)}); + try{await observer.poll();const pending=observer.poll();observer.refresh();resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();} +}); +test('submit without native user activation is excluded',()=>{ + const f=fixture();f.run('install');f.context.navigator.userActivation.isActive=false;const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0); +}); +test('late DOM drain remains serialized and expires without invoking audit',async()=>{ + const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let resolve,calls=0;const seen=[]; + const observer=createHumanInteractionObserver({deadline:100,getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};if(calls===2)return new Promise(r=>{resolve=r;});return null;},onObserved:async event=>seen.push(event)}); + try{await observer.poll();const pending=observer.poll();await new Promise(r=>setTimeout(r,120));await observer.poll();assert.equal(calls,2);resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();} +}); +test('destroyed renderer disposal errors never escape lifecycle callback',async()=>{ + const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let installed=false; + const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:()=>{if(!installed){installed=true;return {installed:true};}throw Error('Destroyed renderer');},onObserved:()=>{}}); + await observer.poll();assert.doesNotThrow(()=>observer.dispose());await new Promise(r=>setImmediate(r)); +}); +test('trusted programmatic submit without recent click or Enter is excluded',()=>{ + const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0); + f.emit('keydown',form,{key:'Enter'});f.advance(1100);f.emit('submit',form);assert.equal(f.run('drain').events.length,0); +}); diff --git a/tests/interaction-evidence-delivery.test.mjs b/tests/interaction-evidence-delivery.test.mjs index c976b7f..23bd1ed 100644 --- a/tests/interaction-evidence-delivery.test.mjs +++ b/tests/interaction-evidence-delivery.test.mjs @@ -5,6 +5,17 @@ const require=createRequire(import.meta.url); const {buildInteractionEvidence,validateInteractionEvidence}=require('../apps/browser/evidence-capture.cjs'); const {collectionAllowed,stripPendingEvidence,deliverAuditRecord,validatedEvidence}=require('../apps/browser/evidence-delivery.cjs'); const sessionId='00000000-0000-4000-8000-000000000001'; +test('human-observer accepts observed click/submit only and uses its own audit source',async()=>{ + const policy={expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}},context={ready:true,deviceId:'device-a',policy}; + for(const action of ['click','submit']){ + const payload=buildInteractionEvidence({action,sessionId,url:'https://example.com/path?secret=1',sensitiveField:false,outcome:'observed',captureSource:'human-observer'},policy); + assert.equal(payload.pageUrl,'https://example.com/');assert.equal(payload.outcome,'observed'); + for(const extra of [{outcome:'completed'},{action:'copy'},{bytes:1},{captureSource:'agent-adapter'}])assert.equal(validateInteractionEvidence({...payload,...extra}),false); + const record={kind:'browser.interaction',eventId:sessionId,protectedEvidence:{deviceId:'device-a',type:'interaction',payload}},routes=[]; + assert.equal((await deliverAuditRecord({record,getContext:()=>context,send:async route=>{routes.push(route);return {id:sessionId,sourceEventId:1};}})).evidenceDelivered,true); + assert.deepEqual(routes,['/api/device/events','/api/device/evidence']);record.kind='agent.action';await assert.rejects(deliverAuditRecord({record,getContext:()=>context,send:async()=>({})}),/source/); + } +}); function fixture(){const context={ready:true,deviceId:'device-a',policy:{expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true,captureInteractions:true}}};const payload=buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com/private/password=secret?q=secret#secret',sensitiveField:false,outcome:'completed',captureSource:'guarded-clipboard',bytes:12},context.policy);const record={eventId:sessionId,kind:'clipboard.copy',protectedEvidence:{deviceId:'device-a',type:'interaction',payload}};return {context,payload,record};} test('automatic interaction capture requires observed completion and excludes path and sensitive data',()=>{const f=fixture();assert.equal(f.payload.pageUrl,'https://example.com/');assert.equal(validateInteractionEvidence(f.payload),true);for(const extra of [{content:'secret'},{contentSha256:'a'.repeat(64)},{target:{tag:'INPUT',label:'secret'}},{sensitiveField:true},{outcome:'authorized'},{captureSource:'page'},{bytes:16777217}])assert.equal(validateInteractionEvidence({...f.payload,...extra}),false);assert.equal(buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com',sensitiveField:false,outcome:'authorized',captureSource:'guarded-clipboard'},f.context.policy),null);}); test('type binding rejects cross-type payloads without attempting evidence send',async()=>{const f=fixture();assert.throws(()=>validatedEvidence(f.payload,'download'),/Invalid/);f.record.protectedEvidence.type='download';const calls=[];await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{calls.push(route);return {};}}),/Invalid/);assert.deepEqual(calls,['/api/device/events']);}); diff --git a/tests/interaction-siem.test.mjs b/tests/interaction-siem.test.mjs index 3627a51..3458ce0 100644 --- a/tests/interaction-siem.test.mjs +++ b/tests/interaction-siem.test.mjs @@ -2,6 +2,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {toECS,toCEF} from '../packages/core/siem.mjs'; const sessionId='00000000-0000-4000-8000-000000000001'; +test('observed browser gestures export as information with unknown outcome',()=>{ + const e={id:2,time:'2026-10-13T12:00:00Z',kind:'browser.interaction',metadata:{interaction:{action:'submit',outcome:'observed',sessionId}}}; + const ecs=toECS(e);assert.deepEqual(ecs.event.type,['info']);assert.equal(ecs.event.outcome,'unknown');assert.equal(ecs.safetynet.interaction.outcome,'observed');assert.ok(toCEF(e).includes('cs3=observed')); + for(const outcome of ['completed','authorized','denied']){e.metadata.interaction.outcome=outcome;assert.equal(toECS(e).safetynet.interaction,undefined);} +}); function record(outcome){return {id:1,time:'2026-10-13T12:00:00Z',kind:outcome==='denied'?'clipboard.block':'clipboard.copy',device_id:'device-a',metadata:{interaction:{action:'copy',outcome,sessionId},ruleId:'clipboard-policy',policyVersion:2,traceId:'1'.repeat(32),spanId:'2'.repeat(16)},audit:{hash:'3'.repeat(64),sequence:1}};} test('ECS and CEF retain interaction phase/session while preserving trace and audit references',()=>{for(const outcome of ['authorized','completed','denied']){const e=record(outcome),ecs=toECS(e),cef=toCEF(e);assert.deepEqual(ecs.safetynet.interaction,{action:'copy',outcome,session_id:sessionId});assert.equal(ecs.trace.id,'1'.repeat(32));assert.ok(cef.includes('cs2Label=InteractionAction cs2=copy'));assert.ok(cef.includes('cs3Label=InteractionOutcome cs3='+outcome));assert.ok(cef.includes('requestContext='+sessionId));assert.ok(cef.includes('flexString1Label=AuditRecordHash'));assert.ok(cef.includes('cs4Label=TraceId'));}}); test('malformed or contradictory interaction metadata never enters security exports',()=>{for(const value of [{action:'paste',outcome:'completed',sessionId},{action:'copy',outcome:'completed',sessionId,content:'PRIVATE_BODY'},{action:'copy',outcome:'completed',sessionId:'private-session'}]){const e=record('completed');e.metadata.interaction=value;assert.equal(toECS(e).safetynet.interaction,undefined);assert.equal(toCEF(e).includes('InteractionAction'),false);assert.equal(JSON.stringify(toECS(e)).includes('PRIVATE_BODY'),false);}}); diff --git a/tests/tab-registry.test.mjs b/tests/tab-registry.test.mjs index 95ea21a..73965da 100644 --- a/tests/tab-registry.test.mjs +++ b/tests/tab-registry.test.mjs @@ -13,3 +13,18 @@ test('quarantine stops hidden and visible tabs, invalidates approvals and denies test('close destroys native contents and cannot revive closed tab capabilities',()=>{const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context();tabs.remove(a.id);assert.equal(a.view.webContents.destroyed,true);assert.equal(tabs.current(token),false);assert.equal(tabs.active(),b);assert.equal(tabs.fromContents(a.view.webContents.id),null);tabs.close();assert.equal(b.view.webContents.destroyed,true);assert.equal(disposed.length,2);assert.throws(()=>tabs.create());}); test('URL mutation and renderer-chosen identifiers cannot reuse native approval',()=>{const {tabs}=fixture();const a=tabs.create();tabs.resume(1);const token=tabs.context();a.view.webContents.url='https://other.test/';assert.equal(tabs.current(token),false);assert.throws(()=>tabs.activate('renderer-forged-id'));assert.equal(Object.isFrozen(token),true);assert.notEqual(a.id,'managed');}); test('capacity is bounded before another renderer process is created',()=>{const {tabs}=fixture({maxTabs:1});tabs.create();assert.throws(()=>tabs.create(),/capacity/);assert.equal(tabs.all().length,1);}); + +// Electron can remove view.webContents before pending startup rejects. The +// native reference must survive solely for deterministic cleanup/revocation. +test('destroyed native view remains removable after its live getter disappears',()=>{ + const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id),contents=a.contents; + assert.throws(()=>{a.contents=b.contents;},TypeError); + assert.equal(Object.keys(a).includes('contents'),false); + contents.destroyed=true;delete a.view.webContents; + assert.equal(tabs.current(token),false);assert.throws(()=>tabs.context(a.id),/Unknown native tab/); + assert.doesNotThrow(()=>tabs.remove(a.id));assert.equal(tabs.fromContents(contents.id),null);assert.equal(disposed.includes(a),true);assert.equal(tabs.active(),b); +}); +test('shutdown cleans already destroyed views without reactivating surviving views',()=>{ + const {tabs,disposed,attached}=fixture();const a=tabs.create(),b=tabs.create();a.contents.destroyed=true;delete a.view.webContents; + const attachments=attached.length;assert.doesNotThrow(()=>tabs.close());assert.equal(disposed.length,2);assert.equal(tabs.all().length,0);assert.equal(attached.length,attachments);assert.equal(b.contents.destroyed,true);assert.doesNotThrow(()=>tabs.close()); +});