From adf0940e5ff44ecbcf0baba59cd580bf67b0b1cc Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 13 Oct 2026 18:24:44 +0200 Subject: [PATCH] Capture guarded human gestures and verify agent administration --- README.md | 6 +- apps/browser/diagnostics.cjs | 24 ++++- apps/browser/evidence-capture.cjs | 4 +- apps/browser/evidence-delivery.cjs | 2 +- apps/browser/human-interaction.cjs | 91 ++++++++++++++++++ apps/browser/main.cjs | 30 +++++- apps/browser/tab-registry.cjs | 23 ++--- apps/server/app.mjs | 4 +- apps/server/evidence.mjs | 11 ++- apps/server/public/admin.js | 2 +- apps/server/public/index.html | 2 +- docs/development-observability.md | 12 +++ docs/individual-agent-grants.md | 10 +- docs/interaction-evidence.md | 31 ++++-- .../media/screenshots/admin-agent-history.png | Bin 0 -> 92643 bytes docs/requirements-audit.md | 63 ++++++++++++ packages/core/evidence-payload.cjs | 8 +- packages/core/interaction-facts.cjs | 3 +- packages/core/siem.mjs | 2 +- scripts/agent-grants-history-admin-smoke.cjs | 19 ++++ scripts/agent-grants-history-admin-smoke.mjs | 21 ++++ scripts/interaction-evidence-electron.cjs | 20 ++-- scripts/interaction-evidence-smoke.mjs | 11 ++- scripts/verify-package.mjs | 2 +- tests/admin-history-profile-review.test.mjs | 5 + tests/diagnostics.test.mjs | 27 ++++++ tests/evidence.test.mjs | 12 +++ tests/human-interaction-integration.test.mjs | 17 ++++ tests/human-interaction.test.mjs | 82 ++++++++++++++++ tests/interaction-evidence-delivery.test.mjs | 11 +++ tests/interaction-siem.test.mjs | 5 + tests/tab-registry.test.mjs | 15 +++ 32 files changed, 523 insertions(+), 52 deletions(-) create mode 100644 apps/browser/human-interaction.cjs create mode 100644 docs/media/screenshots/admin-agent-history.png create mode 100644 scripts/agent-grants-history-admin-smoke.cjs create mode 100644 scripts/agent-grants-history-admin-smoke.mjs create mode 100644 tests/human-interaction-integration.test.mjs create mode 100644 tests/human-interaction.test.mjs 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 0000000000000000000000000000000000000000..3d350b96bc5ee946ae6673370812b88738e8dcea GIT binary patch literal 92643 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

Ccnw2e7jr(tX@=jihzXB5}KMc;}2w)F|OPV#X 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 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()); +});