From 244f01630b12c3ef4dba5488f59a61ba18deed00 Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 13 Oct 2026 18:04:02 +0200 Subject: [PATCH] Add protected interaction evidence and measured debugging phases --- AGENTS.md | 5 ++ README.md | 4 +- apps/browser/agent-adapters.cjs | 27 ++++++++- apps/browser/clipboard-dom.cjs | 9 +-- apps/browser/clipboard-guard.cjs | 11 +++- apps/browser/diagnostics.cjs | 10 ++-- apps/browser/evidence-capture.cjs | 21 ++++++- apps/browser/evidence-delivery.cjs | 30 ++++++---- apps/browser/main.cjs | 47 ++++++++++----- apps/browser/management-channel.cjs | 18 ++++-- apps/server/app.mjs | 4 +- apps/server/evidence.mjs | 19 ++++-- apps/server/public/evidence-policy-admin.js | 6 +- apps/server/public/index.html | 2 +- docs/browser-diagnostics.md | 14 ++++- docs/development-observability.md | 32 ++++++++++ docs/interaction-evidence.md | 36 +++++++++++ docs/requirements-audit.md | 24 ++++++++ packages/core/evidence-payload.cjs | 8 +++ packages/core/evidence.mjs | 13 ++++ packages/core/interaction-facts.cjs | 11 ++++ packages/core/policy.mjs | 3 +- packages/core/siem.mjs | 9 +-- scripts/individual-agent-electron.cjs | 14 ++++- scripts/individual-agent-smoke.mjs | 3 +- scripts/interaction-evidence-electron.cjs | 45 ++++++++++++++ scripts/interaction-evidence-smoke.mjs | 53 ++++++++++++++++ scripts/native-tabs-smoke.cjs | 2 +- scripts/native-tabs-smoke.mjs | 5 +- scripts/verify-package.mjs | 2 +- tests/agent-adapters.test.mjs | 60 +++++++++++++++++++ .../browser-interaction-integration.test.mjs | 11 ++++ tests/clipboard-review.test.mjs | 10 ++++ tests/diagnostics.test.mjs | 12 ++++ tests/evidence-channel-server.test.mjs | 10 ++-- tests/evidence-policy-admin.test.mjs | 5 +- tests/evidence-policy.test.mjs | 2 +- tests/evidence.test.mjs | 51 +++++++++++++--- tests/interaction-evidence-delivery.test.mjs | 13 ++++ tests/interaction-siem.test.mjs | 7 +++ tests/native-diagnostics-attribution.test.mjs | 16 +++++ tests/policy-stage-diagnostics.test.mjs | 43 +++++++++++++ 42 files changed, 640 insertions(+), 87 deletions(-) create mode 100644 docs/interaction-evidence.md create mode 100644 packages/core/evidence-payload.cjs create mode 100644 packages/core/evidence.mjs create mode 100644 packages/core/interaction-facts.cjs create mode 100644 scripts/interaction-evidence-electron.cjs create mode 100644 scripts/interaction-evidence-smoke.mjs create mode 100644 tests/browser-interaction-integration.test.mjs create mode 100644 tests/interaction-evidence-delivery.test.mjs create mode 100644 tests/interaction-siem.test.mjs create mode 100644 tests/native-diagnostics-attribution.test.mjs create mode 100644 tests/policy-stage-diagnostics.test.mjs diff --git a/AGENTS.md b/AGENTS.md index ba02a66..cbd5225 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,11 @@ debugging, optimization and feature work, not merely implemented and left idle. - Check collector `storedBytes` and exporter failures before long debug sessions. The local protected receiver has a finite storage budget; missing exports or rejected writes must not be mistaken for a successful observability proof. +- Check diagnostics `lifetimeFailedExports` as well as the current generation's + `failedExports`; disable/reconfiguration must not erase evidence of earlier + failures within the same browser process. Pre-enrollment startup has no signed + export authorization: use bounded local startup snapshots, and do not infer a + timeout's cause from later successful traces. - Do not hide failed tests, weaken enforcement, or repeat a protected operation because telemetry failed. Diagnostic traces are distinct from durable security audit and encrypted forensic evidence. diff --git a/README.md b/README.md index 1d06215..4a1bf82 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. Protected download metadata evidence and governed analyst access; automatic interaction/content evidence remains incomplete. | +| 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. | | 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). @@ -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. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); native 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 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. diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs index d90fd87..176357e 100644 --- a/apps/browser/agent-adapters.cjs +++ b/apps/browser/agent-adapters.cjs @@ -53,7 +53,10 @@ function domAction(mode, selector, text, expectedUrl) { } if (mode === 'click') { if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked'); - e.click(); return {completed:true}; + const rect=e.getBoundingClientRect(),target={tag:e.tagName.toLowerCase()},role=e.getAttribute('role'); + if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role; + const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);} + e.click(); return {completed:true,target}; } if (mode === 'type') { if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text'); @@ -103,7 +106,7 @@ const hostMatches = (hostname, pattern) => { const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase(); return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p; }; -function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,appControls={}} = {}) { +function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) { if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required'); const snapshot = (args,signal,action) => { const c = getContext(args.sessionId); @@ -142,6 +145,11 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false); check(initial,args,signal); if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)}; + if(mode==='click' && result?.completed===true && onInteraction){ + const source=result.target,target=source&&['a','button','input'].includes(source.tag)?{tag:source.tag}:undefined; + if(target&&['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(source.role))target.role=source.role; + if(target&&['x','y'].every(k=>Number.isSafeInteger(source[k])&&source[k]>=0&&source[k]<=100000)){target.x=source.x;target.y=source.y;} +await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);} return {completed:result?.completed===true}; }; const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; @@ -181,7 +189,20 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his sessions:async(args={}, {signal}={})=>{ const c=getContext(); if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable'); - return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true})); + const version=c.policyVersion??c.policy.version; + const inventory=()=>{const rows=getSessions();if(!Array.isArray(rows)||rows.length>1000)throw Error('Invalid managed inventory');return rows.map(item=>{if(typeof item.title!=='string'||item.title.length>200)throw Error('Invalid managed title');return {id:item.id,title:item.title||'Tab',foreground:item.foreground===true};});}; + const rows=inventory(),binding=JSON.stringify(rows); + const current=()=>{const latest=getContext();if(signal?.aborted||!latest?.ready||!latest.policy?.agents?.enabled||!latest.policy.agents.scopes?.includes('sessions')||latest.policy!==c.policy||(latest.policyVersion??latest.policy.version)!==version||(latest.policy.expiresAt&&Date.parse(latest.policy.expiresAt)<=Date.now())||(c.token&&!contextCurrent(c.token))||JSON.stringify(inventory())!==binding)throw Error('Managed session changed during action');}; + // One bounded classification avoids reloading local OCR/semantic models + // per tab. A blocked aggregate masks every title; opaque IDs remain usable. + const candidates=rows.filter(row=>!secret(row.title)); + let allow=false; + if(candidates.length){ + const verdict=await inspect(candidates.map(row=>row.title).join('\n'),{...c,action:'sessions'});current(); + if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable'); + allow=verdict.decision==='allow'; + } + current();return rows.map(row=>({...row,title:allow&&!secret(row.title)?row.title.slice(0,160):'Protected page'})); }, navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};}, read:dom('read'),click:dom('click'),type:dom('type'),select:dom('select'),check:dom('check'), diff --git a/apps/browser/clipboard-dom.cjs b/apps/browser/clipboard-dom.cjs index a46e8de..4b6af3b 100644 --- a/apps/browser/clipboard-dom.cjs +++ b/apps/browser/clipboard-dom.cjs @@ -4,14 +4,15 @@ function clipboardDOM(mode,url,token,operation,payload,limit){ if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed'); const field=document.activeElement; const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly; - const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');}; + const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');}; const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;}; const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;}; + const targetFacts=(e,rect)=>{const target={tag:/^[A-Z][A-Z0-9]{0,31}$/.test(e.tagName)?e.tagName.toLowerCase():'unknown'};const role=e.getAttribute?.('role');if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;if(rect){const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}}return target;}; if(mode==='stage'){ const snapshot={token,operation,field};let text=''; if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){ if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied'); - snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd; + snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd; if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported'); text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end); }else{ @@ -19,10 +20,10 @@ function clipboardDOM(mode,url,token,operation,payload,limit){ const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable'); const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported'); - snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString(); + snapshot.target=targetFacts(ancestor,range.getBoundingClientRect?.());snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString(); } if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit'); - snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text}; + snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text,target:snapshot.target}; } const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot; if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable'); diff --git a/apps/browser/clipboard-guard.cjs b/apps/browser/clipboard-guard.cjs index a718739..7a53775 100644 --- a/apps/browser/clipboard-guard.cjs +++ b/apps/browser/clipboard-guard.cjs @@ -1,11 +1,12 @@ 'use strict'; const {randomUUID}=require('node:crypto'); -function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){ +function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,onCompleted,timeoutMs=10000}){ + const targetFacts=value=>{if(!value||!['a','button','input','textarea','select','div','span','p','body','unknown'].includes(value.tag))return undefined;const target={tag:value.tag};if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(value.role))target.role=value.role;if(['x','y'].every(k=>Number.isSafeInteger(value[k])&&value[k]>=0&&value[k]<=100000)) {target.x=value.x;target.y=value.y;}return target;}; let pending=null; const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();}; async function run(operation,webContents){ const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false; - const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};}; + const denied=async ruleId=>{await audit('clipboard.block',ruleId,{action:operation,sessionId:c.tabId,outcome:'denied'});return{completed:false,ruleId};}; const work=async()=>{try{ if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied'); const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes); @@ -19,7 +20,7 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip if(!current(c)||expired)return denied('clipboard-context-changed'); if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed'); // Persist authorization before side effects; never audit raw clipboard text. - await audit(`clipboard.${operation}`); + await audit(`clipboard.${operation}`,undefined,{action:operation,sessionId:c.tabId,outcome:'authorized'}); if(!current(c)||expired)return denied('clipboard-context-changed'); const args={operation,token,url:c.url,text,limit,webContents:c.webContents}; if(operation==='cut'){ @@ -31,6 +32,10 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip const result=await commit(args); if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed'); if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');} + if(onCompleted && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(c.tabId||'')) { + await onCompleted({action:operation,sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,...(targetFacts(snapshot.target)?{target:targetFacts(snapshot.target)}:{}),bytes:Buffer.byteLength(text)}); + if(!current(c)||expired)return denied('clipboard-context-changed'); + } return {completed:true}; }finally{try{await discard?.({token,webContents:c.webContents});}catch{}}}; try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);} diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 74fdcd2..9497def 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -10,8 +10,8 @@ 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 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']); -const operations=new Set(['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 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 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']); @@ -34,7 +34,7 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}} function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;} const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000}; -function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0; +function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0; // Parser timing is untrusted metadata: validate the entire batch before creating // spans, and attach it only to a currently recording browser operation. function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return; @@ -42,7 +42,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);} }catch{/* Diagnostics must never change inspection results. */}} function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));} - function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} + function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false; let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0}; const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}} @@ -63,6 +63,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio } async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);} async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);} - return {configure,span,event,importStages,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,activeSpans})}; + return {configure,span,event,importStages,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 b6be7d8..a7561b4 100644 --- a/apps/browser/evidence-capture.cjs +++ b/apps/browser/evidence-capture.cjs @@ -19,4 +19,23 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){ if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId; return validateDownloadEvidence(result)?result:null; } -module.exports={buildDownloadEvidence,validateDownloadEvidence}; +const {InteractionEvidenceSchema}=require('../../packages/core/evidence-payload.cjs'); +function validateInteractionEvidence(value){ + if(!InteractionEvidenceSchema.safeParse(value).success)return false; + 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; + 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; + // Native adapters expose bounded lowercase tags. Preserve their meaning in + // the canonical evidence representation without accepting arbitrary DOM text. + target={...target,tag:target.tag.toUpperCase()}; + } + // 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})}; + 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 ffdc79b..488ef4e 100644 --- a/apps/browser/evidence-delivery.cjs +++ b/apps/browser/evidence-delivery.cjs @@ -1,16 +1,26 @@ 'use strict'; -const {validateDownloadEvidence}=require('./evidence-capture.cjs'); -function collectionAllowed(context){return !!context?.ready&&context.policy?.evidence?.enabled===true&&context.policy.evidence.captureDownloads===true&&Date.parse(context.policy.expiresAt)>Date.now();} -function validatedEvidence(payload){if(!validateDownloadEvidence(payload))throw Error('Invalid protected download evidence');return structuredClone(payload);} -function stripPendingEvidence(queue){return queue.map(({protectedEvidence,...event})=>event);} +const {validateDownloadEvidence,validateInteractionEvidence}=require('./evidence-capture.cjs'); +function evidenceType(record){return record?.protectedEvidence?.type??'download';} +function policyAllows(context,type='download'){return ['download','interaction'].includes(type)&&context?.policy?.evidence?.enabled===true&&context.policy.evidence[type==='download'?'captureDownloads':'captureInteractions']===true&&Date.parse(context.policy.expiresAt)>Date.now();} +function collectionAllowed(context){return !!context?.ready&&policyAllows(context,context?.type??'download');} +function validatedEvidence(payload,type='download'){if(!(type==='download'?validateDownloadEvidence(payload):type==='interaction'&&validateInteractionEvidence(payload)))throw Error('Invalid protected evidence');return structuredClone(payload);} +function stripPendingEvidence(queue,context){return queue.map(record=>{if(!record.protectedEvidence)return record;if(context&&policyAllows(context,evidenceType(record)))return record;const {protectedEvidence,...event}=record;return event;});} async function deliverAuditRecord({record,send,getContext}){ const {protectedEvidence,...event}=record;await send('/api/device/events',event); if(!protectedEvidence)return {eventDelivered:true,evidenceDelivered:false}; - const context=getContext(); - if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&(context.policy.evidence?.enabled!==true||context.policy.evidence.captureDownloads!==true||!(Date.parse(context.policy.expiresAt)>Date.now())))return {eventDelivered:true,evidenceDiscarded:true}; - if(!context?.ready||!context.policy)throw Error('Evidence delivery paused until managed context is ready'); - const payload=validatedEvidence(protectedEvidence.payload); - try{const result=await send('/api/device/evidence',{eventId:event.eventId,type:'download',payload});if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};} + const type=evidenceType(record); + function state(){const context=getContext();if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&!policyAllows(context,type))return 'discard';if(!context?.ready||!context.policy)return 'pause';return 'allow';} + 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'; + 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}); + // A successful network response must not resurrect collection after a + // concurrent policy revocation or a change of enrolled device. + const after=state();if(after==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(after==='pause')throw Error('Evidence delivery paused until managed context is ready'); + if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};} catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;} } -module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord}; +module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord,evidenceType,policyAllows}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index 10ed007..d198bac 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -146,11 +146,14 @@ function managementUrl(value){ return u.origin; } const managementRequests=new Map(); +const evidenceRequestTypes=new Map(),evidenceSendStorage=new (require('node:async_hooks').AsyncLocalStorage)(); function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));} async function requestImpl(server,route,body,credential){ if(route==='/api/device/evidence'){ if(!device||device.server!==server||device.token!==credential)throw Error('Protected evidence identity unavailable'); - return require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body}); + const identity=device,type=body?.type;if(!['download','interaction'].includes(type))throw Error('Invalid protected evidence type'); + const assertCurrent=()=>{if(device!==identity||!require('./evidence-delivery.cjs').policyAllows({policy},type))throw Object.assign(Error('Protected evidence collection disabled'),{status:403});if(!ready)throw Error('Evidence delivery paused until managed context is ready');}; + assertCurrent();return evidenceSendStorage.run({type},()=>require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body,assertCurrent,traceOperation:(operation,fn)=>diagnosticSpan('browser.evidence',{operation},fn)})); } if(route==='/api/device/policy'){ if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable'); @@ -159,13 +162,13 @@ async function requestImpl(server,route,body,credential){ const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`; const correlation=diagnosticCorrelation();if(correlation.traceId&&correlation.spanId)headers.traceparent=`00-${correlation.traceId}-${correlation.spanId}-01`; if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1'; - const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route); + const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);if(evidenceSendStorage.getStore()?.type&&route.startsWith('/api/device/channel/evidence'))evidenceRequestTypes.set(controller,evidenceSendStorage.getStore().type); try{ const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch; const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal}); const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);} const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data; - }finally{clearTimeout(timer);managementRequests.delete(controller);} + }finally{clearTimeout(timer);managementRequests.delete(controller);evidenceRequestTypes.delete(controller);} } function state(){return {startPage:{...(policy?.startPage||{}),news:ready?companyNews:[]},libraryAI:{suggestionsEnabled:ready&&!!policy?.ai.enabled&&!!policy?.library?.aiSuggestionsEnabled&§ionAllowed(managedSettings(),'library')&&featureAllowed(managedSettings(),'ai'),autoApplyEnabled:ready&&!!policy?.library?.aiAutoApplyEnabled&&policy?.ai?.trustMode==='local',busy:libraryOrganizer?.isBusy()||false},bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentIdentityMode:policy?.agents.identityMode||'legacy',agentGrants:ready&&featureAllowed(managedSettings(),'agents')?approvedAgentGrants().map(({id,name,generation,expiresAt})=>({id,name,generation,expiresAt})):[],agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};} function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}} @@ -192,7 +195,10 @@ function persistAudit(){ try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}} } function enforceEvidencePolicy(){ - if(policy&&(policy.evidence?.enabled!==true||policy.evidence.captureDownloads!==true||!(Date.parse(policy.expiresAt)>Date.now()))){for(const [controller,route]of managementRequests)if(route==='/api/device/evidence'||route.startsWith('/api/device/channel/evidence'))controller.abort();if(auditQueue.some(e=>e.protectedEvidence)){auditQueue=require('./evidence-delivery.cjs').stripPendingEvidence(auditQueue);persistAudit();}} + if(!policy)return;const delivery=require('./evidence-delivery.cjs'); + for(const [controller,type]of evidenceRequestTypes)if(!delivery.policyAllows({policy},type))controller.abort(); + const next=delivery.stripPendingEvidence(auditQueue,{policy}); + if(next.some((record,index)=>record!==auditQueue[index])){auditQueue=next;persistAudit();} } async function flushAudit(){ @@ -200,31 +206,35 @@ async function flushAudit(){ 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();}} catch{}finally{flushing=false;} } -async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts){ +async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){ if(!device||!policy)return; // Operational correlation contains no rule text, hostname or forensic payload. try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{} if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;} - try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy})?{protectedEvidence:{deviceId:device.id,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} + try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} catch{quarantine('Security events cannot be persisted. Contact your administrator.');} } function auditAgent(event){ const action=event.action==='agent.read'?'read':event.action||'agent-request',outcome=event.outcome==='blocked'?'denied':event.outcome; const helper=require('../../packages/core/agent-audit.cjs'),session=helper.AgentAuditSchema.shape.sessionId.safeParse(event.sessionId); const agent=helper.agentAuditFacts({action,outcome,...(event.scope?{scope:event.scope}:{}),...(session.success&&session.data?{sessionId:session.data}:{}),...(event.principalId?{principalId:event.principalId,credentialGeneration:event.generation}:{})}); - return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,undefined,agent); + return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,event.protectedInteraction,agent); } function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));} async function applyPolicyImpl(envelope){ - const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id); + const next=await diagnosticSpan('browser.policy',{operation:'policy.verify'},()=>assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id)); + const {nextBookmarks,nextBookmarkJSON}=await diagnosticSpan('browser.policy',{operation:'policy.bookmarks'},async()=>{ const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0}); + const nextBookmarkJSON=JSON.stringify(nextBookmarks.items); if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision'); + return {nextBookmarks,nextBookmarkJSON}; + }); const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);}; if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin'); if(policy&&next.version===policy.version){ // Same versions must describe identical policy; reject equivocation. - if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await reconcileAgentPolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return; + if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return; } configureDiagnostics({enabled:false});quarantine('Applying company policy…'); const proxy=next.proxy; @@ -234,10 +244,10 @@ async function applyPolicyImpl(envelope){ app.configureHostResolver(resolverOptions(next.enterprise?.dns)); await browserSession.clearHostResolverCache();recentRequests.clear(); await managementSession.clearHostResolverCache(); - policy=next;if(!next.evidence.enabled||!next.evidence.captureDownloads)enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); - for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}} - await reconcileAgentPolicy(); - await audit('policy.applied'); + policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); + await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}}); + await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy()); + await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied')); } async function sync(){ if(!device||syncing)return;syncing=true; @@ -385,7 +395,10 @@ async function ensureAgentAPI(){ if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy)); verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result; }if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external; - },audit:auditAgent}); + },audit:auditAgent,onInteraction:async metadata=>{ + const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return; + const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload}); + }}); for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};} for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{ if(context.principalId&&['navigate','back','forward','reload','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};} @@ -640,7 +653,11 @@ app.whenReady().then(async()=>{ readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();}, writeClipboard:text=>clipboard.writeText(text), inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;}, - audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}} + onCompleted:async metadata=>{ + const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'guarded-clipboard'},policy)); + await audit(`clipboard.${metadata.action}`,undefined,undefined,undefined,undefined,payload,undefined,{action:metadata.action,sessionId:metadata.sessionId,outcome:'completed'}); + }, + audit:async(kind,ruleId,facts)=>{const parsed=require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.safeParse(facts);await audit(kind,ruleId,undefined,undefined,undefined,undefined,undefined,parsed.success?parsed.data:undefined);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}} }); installClipboardControls(win.webContents);hooks(); ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};}); diff --git a/apps/browser/management-channel.cjs b/apps/browser/management-channel.cjs index 9782945..49cbf93 100644 --- a/apps/browser/management-channel.cjs +++ b/apps/browser/management-channel.cjs @@ -19,19 +19,25 @@ async function protectedPolicyRequest({device,request,save}){ try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);} finally{context.responseKey.fill(0);} } -async function protectedEvidenceRequest({device,request,save,payload}){ +async function protectedEvidenceRequest({device,request,save,payload,assertCurrent=()=>{},traceOperation=async(_name,fn)=>fn()}){ + assertCurrent(); await initializeSigningPin({device,request,save}); + assertCurrent(); const crypto=channel.evidenceChannel; - const challenge=await request(device.server,'/api/device/channel/evidence/challenge',{},device.token); + const challenge=await traceOperation('evidence.challenge',()=>request(device.server,'/api/device/channel/evidence/challenge',{},device.token)); + assertCurrent(); crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey); - const context=crypto.prepare(challenge,payload); + let context; try{ - const response=await request(device.server,'/api/device/channel/evidence',context.request,device.token); + await traceOperation('evidence.encrypt',()=>{context=crypto.prepare(challenge,payload);}); + assertCurrent(); + const response=await traceOperation('evidence.send',()=>request(device.server,'/api/device/channel/evidence',context.request,device.token)); // Status is trusted only inside the authenticated encrypted acknowledgement. - const result=crypto.finish(response,challenge,context); + assertCurrent(); + const result=await traceOperation('evidence.ack',()=>crypto.finish(response,challenge,context));assertCurrent(); if(!result||Object.keys(result).sort().join(',')!=='body,status'||!Number.isInteger(result.status)||result.status<200||result.status>599||!result.body||typeof result.body!=='object'||Array.isArray(result.body))throw Error('Protected evidence acknowledgement invalid'); if(result.status>=300){const error=Error('Protected evidence submission refused');error.status=result.status;throw error;} return result.body; - }finally{context.responseKey.fill(0);} + }finally{context?.responseKey.fill(0);} } module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin}; diff --git a/apps/server/app.mjs b/apps/server/app.mjs index f4db85d..2e4e950 100644 --- a/apps/server/app.mjs +++ b/apps/server/app.mjs @@ -3,6 +3,7 @@ import {installRequestTelemetry,traceOperation} from './telemetry.mjs'; import transferFacts from '../../packages/core/transfer-facts.cjs'; import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs'; import agentAudit from '../../packages/core/agent-audit.cjs'; +import interactionAudit from '../../packages/core/interaction-facts.cjs'; import {installEvidence} from './evidence.mjs'; import {installIncidents} from './incidents.mjs'; import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs'; @@ -107,7 +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()}).strict().parse(req.body); + 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'}); 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 d8b1f81..7a79767 100644 --- a/apps/server/evidence.mjs +++ b/apps/server/evidence.mjs @@ -5,6 +5,7 @@ import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from import {bootstrapAllowed} from './admin-access.mjs'; import {installEvidenceChannel} from './evidence-channel.mjs'; import {assertPrivateEvidenceKey} from './evidence-key.mjs'; +import {InteractionEvidenceSchema} from '../../packages/core/evidence.mjs'; const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden'); const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment'); @@ -35,11 +36,17 @@ function includesCredential(payload){ } function sourceMatches(source,payload,type){ let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;} - if(type==='interaction'&&['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)&&payload.action!==source.kind.split('.')[1])return false; + if(type==='interaction'){ + if(payload.outcome!=='completed'||!payload.sessionId)return false; + if(source.kind==='agent.action'){ + 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(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false; if(type==='download'&&metadata.file){for(const field of ['sha256','bytes'])if(metadata.file[field]!==undefined&&payload[field]!==metadata.file[field])return false;} if(metadata.host){try{if(new URL(payload.sourceUrl??payload.pageUrl).hostname.toLowerCase()!==String(metadata.host).toLowerCase())return false;}catch{return false;}} - if(type==='interaction'&&source.kind==='agent.action'&&typeof metadata.ruleId==='string'){const action=metadata.ruleId.split(':')[0];if(['click','copy','cut','paste','submit'].includes(action)&&payload.action!==action)return false;} return true; } function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));} @@ -61,10 +68,10 @@ export function installEvidence(app,store,config,device,admin){ function recordEvidence(req,res){ let plain;try{ const b=input.parse(req.body),policy=store.effectivePolicy(req.device.id); - if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads)return res.status(403).json({error:'Evidence collection is disabled by current policy'}); - const payload=payloads[b.type].parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});if(b.type==='interaction'&&payload.content!==undefined&&payload.contentSha256!==undefined&&createHash('sha256').update(payload.content,'utf8').digest('hex')!==payload.contentSha256)return res.status(400).json({error:'Evidence content digest mismatch'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'}); + 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','clipboard.block','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'];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{} @@ -87,7 +94,7 @@ export function installEvidence(app,store,config,device,admin){ app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{ const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'}); const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'}); - let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=payloads[row.type].parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});} + let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=(row.type==='interaction'?z.union([InteractionEvidenceSchema,payloads.interaction]):payloads[row.type]).parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});} // Access must be durably attributable before plaintext leaves the service. store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason}); res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload}); diff --git a/apps/server/public/evidence-policy-admin.js b/apps/server/public/evidence-policy-admin.js index 7823810..286da68 100644 --- a/apps/server/public/evidence-policy-admin.js +++ b/apps/server/public/evidence-policy-admin.js @@ -3,14 +3,16 @@ let evidencePolicyEditable=false; function updateEvidencePolicyControls(){ $('evidence-policy-enabled').disabled=!evidencePolicyEditable; $('evidence-policy-downloads').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked; + $('evidence-policy-interactions').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked; } function fillEvidencePolicy(p){ $('evidence-policy-enabled').checked=p.evidence?.enabled===true; $('evidence-policy-downloads').checked=p.evidence?.captureDownloads===true; + $('evidence-policy-interactions').checked=p.evidence?.captureInteractions===true; updateEvidencePolicyControls(); } const fillBeforeEvidencePolicy=fill;fill=function(p){fillBeforeEvidencePolicy(p);fillEvidencePolicy(p);}; -const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked};return p;}; +const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked,captureInteractions:$('evidence-policy-interactions').checked};return p;}; const permissionsBeforeEvidencePolicy=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeEvidencePolicy(principal);evidencePolicyEditable=!!principal?.permissions?.includes(independentApprovalRequired?'policy.prepare':'policy.write');updateEvidencePolicyControls();}; -for(const id of ['evidence-policy-enabled','evidence-policy-downloads'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();}); +for(const id of ['evidence-policy-enabled','evidence-policy-downloads','evidence-policy-interactions'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();}); if(policy)fillEvidencePolicy(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal); diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 61d5a7e..dafcef9 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

Download evidence collection

Opt in through centrally signed policy. Both 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. This setting does not enable automatic interaction or clipboard-content capture. 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, 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.

diff --git a/docs/browser-diagnostics.md b/docs/browser-diagnostics.md index 5d6c902..4c0e042 100644 --- a/docs/browser-diagnostics.md +++ b/docs/browser-diagnostics.md @@ -34,7 +34,9 @@ The privileged browser instruments fixed operation families: IPC, local agent ac Attributes are explicitly typed and allowlisted: registered operation/component enums, UUID session/device IDs, policy version, bounded byte/count fields, duration, decision, request type and status. Failure spans receive a status and a fixed exception category (`Error`, `TypeError` or `RangeError`), never an exception message or stack. Arbitrary operation names are not recorded. Returned model answers, credential values and inspected content are never inferred as diagnostic attributes. -Detailed timing within Python parsing, OCR, YARA or model inference is not yet exported as its own child spans; the browser records the local-inspector call boundary. Renderer paint/layout and Chromium internal tracing are also separate future work. This is explicit application instrumentation, not a claim to trace every Chromium function. +Signed debug mode imports validated private-pipe timing records as inspector child spans: inspection, parsing, OCR, OCR model initialization, YARA, macro extraction, semantic inference and semantic model initialization. Operational mode retains the call boundary. Renderer paint/layout, individual model kernels and Chromium internal tracing remain separate future work. This is explicit application instrumentation, not a claim to trace every Chromium function. + +Protected evidence delivery has separate capture, challenge, encryption, send and acknowledgement stages, correlated with backend decrypt/store spans. Startup before enrollment or before a verified diagnostics policy cannot export telemetry; local smoke fixtures record bounded startup phase, window loading state and resource snapshots without URLs or credentials. Do not attribute an early startup timeout to a model or CPU pressure without measurements. ## Lifecycle and security behavior @@ -42,6 +44,16 @@ Only verified policy configures the provider. Reconfiguration immediately invali Export failure never repeats the protected operation, changes its result or authorizes blocked data. Successful and failing functions retain their original results/errors. Normal shutdown attempts a bounded flush; diagnostics are intentionally nondurable and can be lost in a crash. Durable audit and forensic evidence require their separate pipelines. +Local status distinguishes current-generation `failedExports` from process-lifetime +`lifetimeFailedExports`. Disabling or changing policy resets the former but retains +the latter until process restart. This counter does not export after disable. + +Policy reconciliation now names signature verification, bookmark verification, +agent reconciliation, page configuration and durable audit phases. Same-version +refreshes preserve correlated child spans under the policy request. A new policy +deliberately disables the previous provider during proxy/DNS changes; only phases +after verified new diagnostics activation can export. That gap is intentional. + ## Verification `node --test tests/diagnostics.test.mjs` exercises a real local OTLP receiver: parent/child traces, measured durations, correlated logs, typed attributes, password/token/URL/error sentinels absent, environment headers/resources excluded, disable/reconfigure queue invalidation, expiry, bounded batches and failed export without operation retry. diff --git a/docs/development-observability.md b/docs/development-observability.md index 45af338..7bcfe99 100644 --- a/docs/development-observability.md +++ b/docs/development-observability.md @@ -64,3 +64,35 @@ when investigating latency. Do not optimize by removing an audit gate or retryin an action. The proof is in `artifacts/individual-agent-native-proof.json`. The final Windows package verifier checks current source and runs both shipped legacy and individual DPAPI/PQ clients successfully. + +## Latest interaction and session measurements + +The actual protected interaction proof uses trace +`32cbbb58088d193301c5f6ac530284af`: 30 spans, verified parent correlation, +capture 0.3353 ms, challenge 8.1735 ms, encryption 0.4718 ms, send 62.1817 ms +and acknowledgement 0.4234 ms. The backend store stage took 54.2629 ms. +Collector rejected writes were zero. These are one local reproduction's +measurements, not performance guarantees. + +After session-title classification was added, the actual native session inventory +proof uses trace `64013e27cfc37bc3792983bc06149c87`: request 85.675 ms, +session adapter 1.0637 ms and durable audit phases 33.3975 and 47.2472 ms. +This fixture did not enable the semantic model; it cannot establish semantic-model +latency. The native proof confirms a confidential title is masked while preserving +its opaque session ID. + +A parallel fixture timed out before enrollment and therefore before signed +diagnostics activation. Its isolated rerun and restart reached the shell in +794/806 ms. Safe local startup snapshots were added; the original timeout's cause +is unresolved. Do not describe that failure as a traced DLP/model regression. +The current Windows package proof and 515-test suite passed before the subsequent +export-counter refinement. Ignored proof artifacts remain local. + +After the refinement and policy child stages, the final suite passes 522 tests. +Real signed policy/bookmark fixtures verify actual OTLP child spans and disabled +transitions. Windows build, packaged-runtime verification and the native tabs +proof pass. The native receiver attributes exports by actual `service.name`: +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. diff --git a/docs/interaction-evidence.md b/docs/interaction-evidence.md new file mode 100644 index 0000000..190efb2 --- /dev/null +++ b/docs/interaction-evidence.md @@ -0,0 +1,36 @@ +# Protected interaction evidence + +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. + +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. + +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 +encrypted legacy evidence remains readable through restricted audited access. + +The source event and typed protected payload are persisted in one DPAPI-protected +local queue record. Delivery acknowledges the ordinary event first and uses the +protected post-quantum management evidence channel. Policy and enrollment are +rechecked throughout delivery. Disabling interaction capture aborts matching +in-flight delivery and removes pending interaction evidence while preserving +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 +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 +credential-submission exception. Do not assume this feature is full page replay +or blanket password-field authorization. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index 00a3985..f655ce4 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -477,3 +477,27 @@ Final current-source full Electron smoke passed in was restarted only after process/listener identity verification and its health check passes; the protected collector remains healthy with zero rejected writes. Grant publication integration is covered by `tests/agent-grants-publication.test.mjs`. + +## Protected interaction and debugging increment + +Default-off signed interaction capture now records confirmed guarded clipboard +operations and completed agent clicks as protected metadata, with exact +device/event/action/session binding. Native proof verifies analyst decryption, +audited access, independent review, live selective disable and continuing download +metadata capture. General physical-click replay and controlled content capture +remain open. See `docs/interaction-evidence.md`. + +Agent session titles now pass aggregate DLP inspection before export; credential +titles are masked and an actual native confidential-title proof preserves the +session ID. Legitimate credential form-submission exceptions remain unverified. + +Evidence delivery has correlated capture/challenge/encrypt/send/ack/store phases. +Policy reconciliation adds fixed named verification/bookmark/page/agent/audit +phases. Export failure totals survive disable/reconfiguration within the process. +Signed policy continues to govern telemetry; pre-enrollment startup uses bounded +local snapshots rather than unauthorized export. These changes have 520 passing +tests, including actual OTLP and signed policy fixtures. Final verification after +service-attributed native diagnostics tests passes 522 tests. The actual native +browser proves diagnostic export stops on revocation; Windows build and packaged +source/runtime verification pass. Production completeness +and the broader original open requirements are not implied. diff --git a/packages/core/evidence-payload.cjs b/packages/core/evidence-payload.cjs new file mode 100644 index 0000000..c8e0848 --- /dev/null +++ b/packages/core/evidence-payload.cjs @@ -0,0 +1,8 @@ +'use strict'; +const {z}=require('zod'); +const InteractionEvidenceSchema=z.object({ + pageUrl:z.string().max(4096).refine(value=>{try{const u=new URL(value);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URL must exclude credentials, query and fragment'), + action:z.enum(['click','copy','cut','paste','submit']),sessionId:z.string().uuid(),outcome:z.literal('completed'),captureSource:z.enum(['guarded-clipboard','agent-adapter']),bytes:z.number().int().min(0).max(16777216).optional(), + 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(); +module.exports={InteractionEvidenceSchema}; diff --git a/packages/core/evidence.mjs b/packages/core/evidence.mjs new file mode 100644 index 0000000..1122997 --- /dev/null +++ b/packages/core/evidence.mjs @@ -0,0 +1,13 @@ +import {z} from 'zod'; + +// Collection controls are signed policy, independent of diagnostics and of +// each other. Neither permission is inferred from the global switch. +export const EvidencePolicySchema=z.object({ + enabled:z.boolean().default(false), + captureDownloads:z.boolean().default(false), + captureInteractions:z.boolean().default(false) +}).strict().prefault({}); + +// Browser and server share the exact strict payload boundary. +import payloads from './evidence-payload.cjs'; +export const InteractionEvidenceSchema=payloads.InteractionEvidenceSchema; diff --git a/packages/core/interaction-facts.cjs b/packages/core/interaction-facts.cjs new file mode 100644 index 0000000..aee644a --- /dev/null +++ b/packages/core/interaction-facts.cjs @@ -0,0 +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(); +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==='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; +} +module.exports={InteractionFactsSchema,interactionFacts,interactionFactsForEvent}; diff --git a/packages/core/policy.mjs b/packages/core/policy.mjs index e299abe..3460cf0 100644 --- a/packages/core/policy.mjs +++ b/packages/core/policy.mjs @@ -11,6 +11,7 @@ import {FileTransfersSchema} from './file-transfer.mjs'; import {UserAccessSchema} from './user-profiles.mjs'; import {LibraryPolicySchema} from './library.mjs'; import {BrandSchema,StartPageSchema} from './branding.mjs'; +import {EvidencePolicySchema} from './evidence.mjs'; const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); export const PolicySchema = z.object({ @@ -18,7 +19,7 @@ export const PolicySchema = z.object({ assignment:GroupAssignmentSchema.optional(), userAccess:UserAccessSchema, diagnostics:DiagnosticsSchema.prefault({}), - evidence:z.object({enabled:z.boolean().default(false),captureDownloads:z.boolean().default(false)}).strict().prefault({}), + evidence:EvidencePolicySchema, fileTransfers:FileTransfersSchema.prefault({}), library:LibraryPolicySchema, expiresAt: z.string().datetime(), diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs index 1441c97..cff80f5 100644 --- a/packages/core/siem.mjs +++ b/packages/core/siem.mjs @@ -1,10 +1,11 @@ +import interactionAudit from './interaction-facts.cjs'; import agentAudit from './agent-audit.cjs'; import transferFacts from './transfer-facts.cjs'; import requestTransferFacts from './request-transfer-facts.cjs'; const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;}; const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;}; export function toECS(event,installationId='safetynet') { - const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; + const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked'; const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.'); return { @@ -19,7 +20,7 @@ export function toECS(event,installationId='safetynet') { ...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}), ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}), ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}), - safetynet:{...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null), + safetynet:{...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null), content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null, ...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})} }; @@ -28,10 +29,10 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\ const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n'); const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3); export function toCEF(event,installationId='safetynet') { - const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert'; + const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert'; // suid identifies the verified agent capability principal, not an employee account. // suser, when present, remains the independently supplied human actor. - const fields={...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, + const fields={...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, ...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}), ...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}), ...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}), diff --git a/scripts/individual-agent-electron.cjs b/scripts/individual-agent-electron.cjs index b071c02..12b7951 100644 --- a/scripts/individual-agent-electron.cjs +++ b/scripts/individual-agent-electron.cjs @@ -3,10 +3,13 @@ const {app,BrowserWindow}=require('electron'); const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs'); const wait=ms=>new Promise(r=>setTimeout(r,ms)),headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; -async function until(fn){for(let n=0;n<200;n++){const result=await fn();if(result)return result;await wait(100);}throw Error('Individual native fixture timed out');} +const startupAt=Date.now(); +function safeSnapshot(stage){return {stage,elapsedMs:Date.now()-startupAt,phase:process.env.SAFETYNET_SMOKE_PHASE,windows:BrowserWindow.getAllWindows().map(w=>({destroyed:w.isDestroyed(),loading:w.webContents.isLoading(),urlKind:w.webContents.getURL().endsWith('index.html')?'shell':w.webContents.getURL()===''?'empty':'other',children:w.contentView.children.length})),metrics:app.getAppMetrics().map(m=>({type:m.type,cpu:m.cpu.percentCPUUsage,idleWakeupsPerSecond:m.cpu.idleWakeupsPerSecond,memoryWorkingSetSize:m.memory.workingSetSize}))};} +async function until(fn,stage='native-wait'){const started=Date.now();for(let n=0;n<200;n++){const result=await fn();if(result)return result;await wait(100);}fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-timeout-'+process.env.SAFETYNET_SMOKE_PHASE+'.json'),JSON.stringify({...safeSnapshot(stage),waitElapsedMs:Date.now()-started},null,2));throw Error('Individual native fixture timed out: '+stage);} const overview=async()=> (await fetch(process.env.SAFETYNET_SMOKE_SERVER+'/api/admin/overview',{headers})).json(); app.whenReady().then(async()=>{let page;try{ - const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;}); + const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;},'initial-shell-load'); + fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-startup-'+process.env.SAFETYNET_SMOKE_PHASE+'.json'),JSON.stringify(safeSnapshot('shell-ready'),null,2)); if(process.env.SAFETYNET_SMOKE_PHASE==='restart'){ await until(async()=> (await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready); page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents; @@ -17,6 +20,7 @@ app.whenReady().then(async()=>{let page;try{ const proofFile=path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),proof=JSON.parse(fs.readFileSync(proofFile,'utf8'));proof.durableGenerationFloorAfterRestart=true;proof.oldDescriptorRejectedAfterRestart=true;fs.writeFileSync(proofFile,JSON.stringify(proof,null,2)); require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);console.log('Individual generation floor restart proof passed');app.exit(0);return; } + page=win.contentView.children.find(v=>v.webContents!==win.webContents)?.webContents; const enroll=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`);assert.equal(enroll.ok,true); page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()); assert.equal((await win.webContents.executeJavaScript('window.safetynet.agentConnection()')).ok,false); @@ -32,6 +36,10 @@ app.whenReady().then(async()=>{let page;try{ const requestFile=path.join(app.getPath('userData'),'individual-fixture-request.json'); async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};} const sessions=await call(a,'sessions');assert.equal(sessions.exit,0);const sessionId=sessions.result[0].id; + assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/protected-title'})).exit,0); + await until(()=>page.getTitle()==='Synthetic confidential inventory title','protected-title-load'); + const protectedInventory=await call(a,'sessions');assert.equal(protectedInventory.exit,0);assert.equal(protectedInventory.result.find(row=>row.id===sessionId)?.title,'Protected page','Page-controlled confidential title escaped inventory classification'); + assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/allowed'})).exit,0); assert.equal((await call(a,'read',{sessionId})).exit,0);assert.equal((await call(a,'application:state')).exit,1);assert.equal((await call(b,'application:state')).exit,0);assert.equal((await call(b,'read',{sessionId})).exit,1); assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/allowed'})).exit,0,'Approved host navigation failed'); assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_FORBIDDEN})).exit,1,'Actor reached forbidden host'); @@ -51,6 +59,6 @@ app.whenReady().then(async()=>{let page;try{ assert.ok(traceProof,'Actual agent request trace missing'); const root=traceProof.stages.find(s=>s.operation==='agent.request');assert.equal(root.parentSpanId,null); for(const operation of ['agent.decrypt','agent.authorize','agent.dispatch','agent.project','agent.audit','agent.encrypt'])assert.ok(traceProof.stages.some(s=>s.operation===operation&&s.parentSpanId===root.spanId&&Number.isFinite(s.durationMs)&&s.durationMs>=0),'Missing correlated stage '+operation); - fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),JSON.stringify({actualElectron:true,explicitGrantSelection:true,renderedGrantChoices:true,uiPairingBothActors:true,separateDPAPIDescriptors:true,noLegacyDescriptor:true,twoActors:true,scopeIntersection:true,approvedHostNavigation:true,forbiddenHostDeniedBeforeTransmission:true,forbiddenRedirectDeniedBeforeTransmission:true,forbiddenReceiverRequests:0,humanNavigationPreserved:true,ownedTabFetchAfterHumanNavigation:true,ownerlessSessionFetchBlockedBeforeTransmission:true,ownerlessReceiverRequests:0,targetedRevocation:true,unaffectedPeer:true,liveGenerationRollbackDenied:true,attributedAudit:true,traceProof},null,2));console.log('Individual agent native proof passed'); + fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),JSON.stringify({actualElectron:true,classifiedInventoryRedactsTitlePreservesId:true,explicitGrantSelection:true,renderedGrantChoices:true,uiPairingBothActors:true,separateDPAPIDescriptors:true,noLegacyDescriptor:true,twoActors:true,scopeIntersection:true,approvedHostNavigation:true,forbiddenHostDeniedBeforeTransmission:true,forbiddenRedirectDeniedBeforeTransmission:true,forbiddenReceiverRequests:0,humanNavigationPreserved:true,ownedTabFetchAfterHumanNavigation:true,ownerlessSessionFetchBlockedBeforeTransmission:true,ownerlessReceiverRequests:0,targetedRevocation:true,unaffectedPeer:true,liveGenerationRollbackDenied:true,attributedAudit:true,traceProof},null,2));console.log('Individual agent native proof passed'); require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0); }catch(error){console.error(error.stack);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}}); diff --git a/scripts/individual-agent-smoke.mjs b/scripts/individual-agent-smoke.mjs index 4197c23..d37bb10 100644 --- a/scripts/individual-agent-smoke.mjs +++ b/scripts/individual-agent-smoke.mjs @@ -17,9 +17,10 @@ function requireSeparator(){return process.platform==='win32'?'\\':'/';} const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); let forbiddenRequests=0,ownerlessRequests=0;const forbidden=createServer((_req,res)=>{forbiddenRequests++;res.end('Forbidden receiver');}).listen(0,'127.0.0.2');await new Promise(r=>forbidden.once('listening',r)); const forbiddenUrl=`http://127.0.0.2:${forbidden.address().port}/forbidden`; -const sink=createServer((req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({forbiddenRequests,ownerlessRequests}));return;}if(req.url==='/ownerless')ownerlessRequests++;if(req.url==='/redirect'){res.writeHead(302,{location:forbiddenUrl});res.end();return;}res.setHeader('content-type','text/html');res.end('Individual actor fixture

Public actor fixture

');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); +const sink=createServer((req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({forbiddenRequests,ownerlessRequests}));return;}if(req.url==='/ownerless')ownerlessRequests++;if(req.url==='/redirect'){res.writeHead(302,{location:forbiddenUrl});res.end();return;}res.setHeader('content-type','text/html');res.end(req.url==='/protected-title'?'Synthetic confidential inventory title

Public actor fixture

':'Individual actor fixture

Public actor fixture

');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`; p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','navigate','read','application:state','settings:get'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'reader-a',name:'Reader A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','navigate','read'],allowedHosts:['127.0.0.1'],generation:1},{id:'state-b',name:'State B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['application:state'],allowedHosts:['127.0.0.1'],generation:1}]}; +p.dlp.rules.push({id:'inventory-confidential-fixture',label:'Synthetic inventory fixture',action:'block',kind:'literal',value:'Synthetic confidential inventory title'}); p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'individual-fixture'); const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true}; const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; diff --git a/scripts/interaction-evidence-electron.cjs b/scripts/interaction-evidence-electron.cjs new file mode 100644 index 0000000..8eb9d5f --- /dev/null +++ b/scripts/interaction-evidence-electron.cjs @@ -0,0 +1,45 @@ +'use strict'; +const {app,BrowserWindow,clipboard,dialog}=require('electron'); +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)); +let stage='startup',page,originalClipboard; +async function until(fn){const end=Date.now()+45000;while(Date.now(){try{ + let win;await until(()=>{win=BrowserWindow.getAllWindows()[0];return win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html');});await wait(300); + const result=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`);assert.equal(result.ok,true);await wait(600); + page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents; + const state=()=>win.webContents.executeJavaScript('window.safetynet.state()').then(r=>r.data),base=process.env.SAFETYNET_SMOKE_SERVER; + const headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; + const overview=()=>fetch(base+'/api/admin/overview',{headers}).then(r=>r.json()); + const records=async()=>((await overview()).events||[]).filter(e=>e.kind==='evidence.recorded'&&e.metadata.evidenceType==='interaction'); + async function key(code){page.focus();page.sendInputEvent({type:'keyDown',keyCode:code,modifiers:['control']});page.sendInputEvent({type:'keyUp',keyCode:code,modifiers:['control']});await wait(800);await until(async()=>!(await state()).clipboardPending);} + originalClipboard=await clipboard.readText(); + stage='copy';await page.executeJavaScript("(()=>{const r=document.createRange();r.selectNodeContents(document.getElementById('clipboard-source'));const s=getSelection();s.removeAllRanges();s.addRange(r)})()");await key('C');assert.equal(await clipboard.readText(),'Synthetic public selection');await until(async()=>(await records()).length===1); + stage='paste';await clipboard.writeText('Synthetic pasted text');await page.executeJavaScript("document.getElementById('public-input').focus()");await key('V');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'Synthetic pasted text');await until(async()=>(await records()).length===2); + stage='cut';await page.executeJavaScript("(()=>{const e=document.getElementById('public-input');e.focus();e.select()})()");await key('X');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'');assert.equal(await clipboard.readText(),'Synthetic pasted text');await until(async()=>(await records()).length===3); + stage='password exclusion';await page.executeJavaScript("document.getElementById('private-input').focus()");await clipboard.writeText('Synthetic password attempt');await key('V');assert.equal(await page.executeJavaScript("document.getElementById('private-input').value"),'never-export-this');await wait(600);assert.equal((await records()).length,3); + stage='agent click';const connection=await win.webContents.executeJavaScript('window.safetynet.agentConnection()');assert.equal(connection.ok,true); + 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); + 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')); + 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); + 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})()")); + const crop=await adminWindow.webContents.executeJavaScript("(()=>{const section=document.getElementById('evidence-policy-controls');section.scrollIntoView({block:'start'});const r=section.getBoundingClientRect();return {x:Math.max(0,Math.floor(r.x)),y:Math.max(0,Math.floor(r.y)),width:Math.min(innerWidth-Math.max(0,Math.floor(r.x)),Math.ceil(r.width)),height:Math.min(innerHeight-Math.max(0,Math.floor(r.y)),Math.ceil(r.height))}})()"); + await wait(200);fs.writeFileSync(path.resolve('artifacts/interaction-evidence-admin-controls.png'),(await adminWindow.webContents.capturePage(crop)).toPNG());adminWindow.destroy(); + 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='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)); + 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);}}); diff --git a/scripts/interaction-evidence-smoke.mjs b/scripts/interaction-evidence-smoke.mjs new file mode 100644 index 0000000..3ddd761 --- /dev/null +++ b/scripts/interaction-evidence-smoke.mjs @@ -0,0 +1,53 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { mkdtempSync,rmSync,mkdirSync,writeFileSync,readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join,resolve } from 'node:path'; +import { spawn,execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { createApp } from '../apps/server/app.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +import { openStore,digest } from '../apps/server/store.mjs'; +const require=createRequire(import.meta.url),electron=require('electron'); +if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)process.env.OTEL_EXPORTER_OTLP_ENDPOINT=process.env.SAFETYNET_TEST_OTLP_ENDPOINT; +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 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)}}); +const inspectorPort=await new Promise((resolve,reject)=>{ + let output='';const deadline=setTimeout(()=>{inspectorProcess.kill();reject(Error('Fixture inspector did not start'));},40000); + inspectorProcess.once('exit',()=>{clearTimeout(deadline);reject(Error('Fixture inspector exited before startup'));}); + inspectorProcess.on('error',reject); + inspectorProcess.stdout.on('data',chunk=>{ + output+=chunk; + if(output.includes('\n')){ + clearTimeout(deadline); + try{resolve(JSON.parse(output.split('\n')[0]).port);}catch{reject(Error('Fixture inspector startup rejected'));} + } + }); + inspectorProcess.stderr.on('data',()=>{}); +}); +const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.brand={name:'Acme Secure Browser',color:'#245fbc'};p.dlp.rules=[{id:'secret-roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'smoke-user'); +p.evidence={enabled:true,captureDownloads:true,captureInteractions:true};p.browser.allowDownloads=true;p.security.yara={enabled:true,rules:[{id:'test-marker',name:'Controlled malicious marker',source:'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}],maxScanBytes:1048576};p.agents={enabled:true,scopes:['sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'],allowedHosts:['127.0.0.1'],allowSensitiveRead:false};if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p); +const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,evidenceKeyFile,requirePQManagement:true,requireIndependentPolicyApproval:true,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.on('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; + +const reviewerToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewerToken),Date.now()+600000,JSON.stringify({sub:'independent-fixture-reviewer',role:'admin'})); +const child=spawn(electron,['scripts/interaction-evidence-electron.cjs'],{stdio:'inherit',windowsHide:true,env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewerToken}}); +const timeout=setTimeout(()=>child.kill(),180000);const exitCode=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timeout); +for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();inspectorProcess.kill(); +// Only this mkdtemp-created fixture directory is removed. +if(!temporary.startsWith(join(tmpdir(),'safetynet-smoke-')))throw Error('Fixture cleanup boundary invalid');rmSync(temporary,{recursive:true,force:true});await smokeTelemetry?.shutdown(); +if(exitCode===0&&process.env.SAFETYNET_TEST_OTLP_ENDPOINT){ + const proof=JSON.parse(readFileSync(resolve('artifacts/interaction-evidence-native-proof.json'),'utf8')),endpoint=process.env.SAFETYNET_TEST_OTLP_ENDPOINT; + const health=await fetch(endpoint+'/health').then(r=>r.json());if(health.rejectedWrites!==0)throw Error('Collector reports rejected writes'); + await new Promise(resolve=>setTimeout(resolve,3000)); + 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'); + 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/native-tabs-smoke.cjs b/scripts/native-tabs-smoke.cjs index 3966deb..fa1fcb7 100644 --- a/scripts/native-tabs-smoke.cjs +++ b/scripts/native-tabs-smoke.cjs @@ -30,6 +30,6 @@ app.whenReady().then(async()=>{try{ const collectorStatus=async()=>await (await fetch(process.env.SAFETYNET_TABS_COLLECTOR+'/status')).json();await until(async()=>{const status=await collectorStatus();return ['browser.ipc','browser.navigation','browser.dns','browser.request'].every(name=>status.names.includes(name))&&status.logs>0&&status.parentChild&&status.correlatedLogs;},'Actual native diagnostics export'); const enrolled=(await state()).deviceId;const response=await fetch(`${process.env.SAFETYNET_TABS_SERVER}/api/admin/devices/${enrolled}/revoke`,{method:'POST',headers:{Authorization:`Bearer ${process.env.SAFETYNET_TABS_ADMIN}`,'content-type':'application/json'},body:'{}'});assert.equal(response.status,200);await until(async()=>!(await state()).ready,'Central revocation'); for(const page of [revokePageA,revokePageB]){assert.equal(page.isDestroyed(),false,'Revocation proof lost native view before request assertion');assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(base+'/revoked-public')},{method:'POST',body:'Public after revocation'}).then(()=>false).catch(()=>true)`),true,'Revoked background/foreground page sent request');} - await wait(500);const stoppedExports=(await collectorStatus()).exports;for(let i=0;i<5;i++)await state();await wait(600);assert.equal((await collectorStatus()).exports,stoppedExports,'Revoked browser continued diagnostic export'); + await wait(500);const stoppedExports=await collectorStatus();for(let i=0;i<5;i++)await state();await wait(600);const afterRevocation=await collectorStatus();assert.ok(stoppedExports.exportsByService.browser>0,'Browser export attribution missing');assert.equal(afterRevocation.exportsByService.browser,stoppedExports.exportsByService.browser,'Revoked browser continued diagnostic export');fs.writeFileSync('artifacts/native-tabs-revocation-diagnostics.json',JSON.stringify({before:stoppedExports.exportsByService,after:afterRevocation.exportsByService,aggregateBefore:stoppedExports.exports,aggregateAfter:afterRevocation.exports},null,2)); const report={actualNativeWebContents:true,actualChromeUi:true,independentDomAndScroll:true,noReloadOnSwitch:true,independentHistory:true,allTabsDlp:true,siteClipboardDenied:true,remoteNodeUnavailable:true,closedContentsDestroyed:true,lastTabFreshGuard:true,centralRevocationAllTabs:true,actualBrowserDiagnostics:true,diagnosticsStoppedOnRevocation:true};fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/native-tabs-proof.json',JSON.stringify(report,null,2));console.log('NATIVE_TABS_PROOF_OK '+JSON.stringify(report));finish(0); }catch(error){console.error('NATIVE_TABS_PROOF_FAILED '+error.stack);finish(1);}}); diff --git a/scripts/native-tabs-smoke.mjs b/scripts/native-tabs-smoke.mjs index 0f129b3..31d1174 100644 --- a/scripts/native-tabs-smoke.mjs +++ b/scripts/native-tabs-smoke.mjs @@ -11,7 +11,10 @@ import {startTelemetry} from '../apps/server/telemetry.mjs'; import {openStore,digest} from '../apps/server/store.mjs'; const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-native-tabs-')); let sink,management,collector,store,child,sdk;const previousEndpoint=process.env.OTEL_EXPORTER_OTLP_ENDPOINT;const requests={},posts=[],diagnosticRequests=[];let privateSentinels=[]; -const diagnosticStatus=()=>{const spans=diagnosticRequests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);const logs=diagnosticRequests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);return {exports:diagnosticRequests.length,names:[...new Set(spans.map(span=>span.name))],spans:spans.length,logs:logs.length,timings:spans.every(span=>BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano)&&span.attributes.some(a=>a.key==='safetynet.duration.ms')),parentChild:spans.some(child=>child.parentSpanId&&spans.some(parent=>parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTrace:spans.some(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTraceIds:[...new Set(spans.filter(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)).map(span=>span.traceId))],correlatedLogs:logs.some(log=>log.traceId&&spans.some(span=>span.traceId===log.traceId&&span.spanId===log.spanId)),sensitivePresent:privateSentinels.some(secret=>JSON.stringify(diagnosticRequests).includes(secret))};}; +// Backend and browser deliberately share this test receiver. Count requests per +// fixed OTLP resource identity so backend flushes cannot impersonate browser exports. +function exportCountsByService(records){const counts={browser:0,controlPlane:0,unknown:0};for(const record of records){const groups=[...(record.payload.resourceSpans||[]),...(record.payload.resourceLogs||[]),...(record.payload.resourceMetrics||[])],services=new Set(groups.map(group=>group.resource?.attributes?.find(a=>a.key==='service.name')?.value?.stringValue));if(services.has('safetynet-browser'))counts.browser++;if(services.has('safetynet-control-plane'))counts.controlPlane++;if(!services.size||[...services].some(v=>v!=='safetynet-browser'&&v!=='safetynet-control-plane'))counts.unknown++;}return counts;} +const diagnosticStatus=()=>{const spans=diagnosticRequests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);const logs=diagnosticRequests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);return {exports:diagnosticRequests.length,exportsByService:exportCountsByService(diagnosticRequests),names:[...new Set(spans.map(span=>span.name))],spans:spans.length,logs:logs.length,timings:spans.every(span=>BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano)&&span.attributes.some(a=>a.key==='safetynet.duration.ms')),parentChild:spans.some(child=>child.parentSpanId&&spans.some(parent=>parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTrace:spans.some(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTraceIds:[...new Set(spans.filter(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)).map(span=>span.traceId))],correlatedLogs:logs.some(log=>log.traceId&&spans.some(span=>span.traceId===log.traceId&&span.spanId===log.spanId)),sensitivePresent:privateSentinels.some(secret=>JSON.stringify(diagnosticRequests).includes(secret))};}; try{ collector=createServer((req,res)=>{if(req.method==='GET'&&req.url==='/status'){res.setHeader('content-type','application/json');res.end(JSON.stringify(diagnosticStatus()));return;}const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{diagnosticRequests.push({path:req.url,payload:JSON.parse(Buffer.concat(chunks).toString())});res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(r=>collector.once('listening',r));const collectorUrl=`http://127.0.0.1:${collector.address().port}`;process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collectorUrl;sdk=startTelemetry(); sink=createServer((req,res)=>{const path=new URL(req.url,'http://localhost').pathname;requests[path]=(requests[path]||0)+1; diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index d1eccda..daf3c2a 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 ['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 ['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/agent-adapters.test.mjs b/tests/agent-adapters.test.mjs index f4cd10d..f7ef2b4 100644 --- a/tests/agent-adapters.test.mjs +++ b/tests/agent-adapters.test.mjs @@ -304,3 +304,63 @@ test('history can leave a start page but reload never invokes native callback th await assert.rejects(f.actions.back({}),/Native target/);await assert.rejects(f.actions.forward({}),/Native target/); await assert.rejects(f.actions.reload({}),/destination blocked/);assert.equal(called,2); }); + +test('completed click forensic callback excludes selectors and text, and context changes suppress it',async()=>{ + const events=[];const f=fixture({onInteraction:async value=>events.push(value)}); + f.context.webContents.executeJavaScript=async()=>({completed:true,target:{tag:'button',role:'button',x:12,y:24,text:'SECRET',selector:'#private'}}); + assert.deepEqual(await f.actions.click({selector:'#public'}),{completed:true}); + assert.deepEqual(events,[{action:'click',sessionId:f.id,pageUrl:'https://work.example/',outcome:'completed',sensitiveField:false,target:{tag:'button',role:'button',x:12,y:24}}]); + f.context.webContents.executeJavaScript=async()=>{f.invalidate();return{completed:true,target:{tag:'button'}};}; + await assert.rejects(f.actions.click({selector:'#public'}));assert.equal(events.length,1); +}); +test('completed click callback failure is surfaced without repeating the committed click',async()=>{ + let calls=0;const f=fixture({onInteraction:async()=>{throw Error('Evidence unavailable');}}); + f.context.webContents.executeJavaScript=async()=>{calls++;return{completed:true};}; + await assert.rejects(f.actions.click({selector:'#public'}),/Evidence unavailable/);assert.equal(calls,1); +}); + +test('session inventory classifies full titles and redacts corporate, semantic and credential content',async()=>{ + for(const kind of ['corporate','semantic','credential','boundary']){ + let calls=0,seen;const title=kind==='credential'?'password=SyntheticCredential':kind==='boundary'?'x'.repeat(150)+' password=SyntheticCredential':'Synthetic confidential title'; + const f=fixture({getSessions:()=>[{id:'inventory',title,foreground:false}],inspect:async text=>{calls++;seen=text;return {decision:'block',ruleId:kind==='semantic'?'semantic-confidential':'corporate'};}}); + f.context.policy.agents.allowSensitiveRead=true; + assert.deepEqual(await f.actions.sessions(),[{id:'inventory',title:'Protected page',foreground:false}]); + assert.equal(calls,['credential','boundary'].includes(kind)?0:1);if(calls)assert.equal(seen,title); + } + const title='x'.repeat(170),f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>{assert.equal(text.length,170);return {decision:'allow'};}}); + f.setUrl('about:blank');assert.equal((await f.actions.sessions())[0].title.length,160); +}); + +test('session inventory refuses unavailable inspection, changed policy, native context and inventory',async()=>{ + for(const kind of ['missing','outage','incomplete','throw','abort','version','policy','token','title']){ + const controller=new AbortController();let title='Safe title',f; + f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async()=>{ + if(kind==='missing')return undefined;if(kind==='outage')return {decision:'block',ruleId:'inspector-unavailable'}; + if(kind==='incomplete')return {decision:'block',ruleId:'corporate',coverage:{complete:false}};if(kind==='throw')throw Error('Scanner failed'); + if(kind==='abort')controller.abort();if(kind==='version')f.setVersion(2);if(kind==='policy')f.context.policy={...f.context.policy};if(kind==='token')f.invalidate();if(kind==='title')title='Changed title'; + return {decision:'allow'}; + }}); + await assert.rejects(f.actions.sessions({}, {signal:controller.signal}),/unavailable|changed|Scanner failed/); + } +}); + +test('session titles use real core decoding and classify corporate suffix beyond wire truncation',async()=>{ + for(const title of ['sk-SyntheticFixture123456789012345','x'.repeat(160)+' confidential']){ + const f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>inspectPolicy(Buffer.from(text),{dlp:{maxBytes:10000,rules:[{id:'corporate',kind:'literal',value:'confidential'}]}})}); + assert.equal((await f.actions.sessions())[0].title,'Protected page'); + } + const title='sk%2DSyntheticFixture123456789012345',f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>inspectPolicy(Buffer.from(text),{dlp:{maxBytes:10000,rules:[]}})}); + assert.equal((await f.actions.sessions())[0].title,'Protected page'); +}); + +test('inventory uses one bounded aggregate scan and masks all titles when any classified title blocks',async()=>{ + for(const decision of ['allow','block']){ + let calls=0;const rows=[{id:'public',title:'Public page',foreground:true},{id:'corporate',title:'Registered corporate page',foreground:false},{id:'credential',title:'password=SyntheticCredential',foreground:false}]; + const f=fixture({getSessions:()=>rows,inspect:async text=>{calls++;assert.equal(text,'Public page\nRegistered corporate page');return {decision,ruleId:decision==='block'?'corporate':null};}}); + const result=await f.actions.sessions();assert.equal(calls,1);assert.deepEqual(result.map(row=>row.id),rows.map(row=>row.id)); + assert.deepEqual(result.map(row=>row.title),decision==='allow'?['Public page','Registered corporate page','Protected page']:['Protected page','Protected page','Protected page']); + } + for(const rows of [Array.from({length:1001},()=>({id:'inventory',title:'Safe'})),[{id:'inventory',title:'x'.repeat(201)}]]){ + let calls=0;const f=fixture({getSessions:()=>rows,inspect:async()=>{calls++;return {decision:'allow'};}});await assert.rejects(f.actions.sessions(),/Invalid managed/);assert.equal(calls,0); + } +}); diff --git a/tests/browser-interaction-integration.test.mjs b/tests/browser-interaction-integration.test.mjs new file mode 100644 index 0000000..8e7f4b0 --- /dev/null +++ b/tests/browser-interaction-integration.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'); +const delivery=require('../apps/browser/evidence-delivery.cjs'); +function fixture(){const writes=[],aborts=[];const policy={version:1,expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true,captureInteractions:true}};const c=vm.createContext({policy,ready:true,device:{id:'device-a'},auditQueue:[],evidenceRequestTypes:new Map(),require:id=>id.startsWith('node:')?require(id):id.includes('evidence-delivery')?delivery:require('../packages/core/'+id.split('/').at(-1)),persistAudit:()=>writes.push('persist'),flushAudit(){},diagnostics:{event(){}},diagnosticContext:x=>x,diagnosticCorrelation:()=>({}),quarantine:()=>{throw Error('Unexpected quarantine');}});vm.runInContext(source.slice(source.indexOf('function enforceEvidencePolicy(){'),source.indexOf('async function flushAudit()')),c);vm.runInContext(source.slice(source.indexOf('async function audit(kind'),source.indexOf('function auditAgent(event)')),c);return {c,writes,aborts};} +const sessionId='00000000-0000-4000-8000-000000000001'; +test('browser persists source completion and protected typed evidence in one audit tuple',async()=>{const f=fixture(),payload={pageUrl:'https://example.test/',action:'copy',sessionId,outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false,target:{tag:'INPUT'},bytes:8};await f.c.audit('clipboard.copy',undefined,undefined,undefined,undefined,payload,undefined,{action:'copy',sessionId,outcome:'completed'});assert.equal(f.writes.length,1);const record=f.c.auditQueue[0];assert.equal(record.interaction.outcome,'completed');assert.equal(record.protectedEvidence.type,'interaction');assert.equal(record.protectedEvidence.payload.target.tag,'INPUT');assert.equal(record.pageUrl,undefined);assert.equal(record.target,undefined);assert.equal(record.eventId.length,36);}); +test('type-specific policy disable aborts only matching sends and strips only matching pending evidence',()=>{const f=fixture(),ordinary={eventId:'ordinary'},download={eventId:'download',protectedEvidence:{type:'download'}},interaction={eventId:'interaction',protectedEvidence:{type:'interaction'}};f.c.auditQueue=[ordinary,download,interaction];for(const type of ['download','interaction'])f.c.evidenceRequestTypes.set({abort:()=>f.aborts.push(type)},type);f.c.policy.evidence.captureInteractions=false;f.c.enforceEvidencePolicy();assert.deepEqual(f.aborts,['interaction']);assert.equal(f.c.auditQueue[0],ordinary);assert.equal(f.c.auditQueue[1],download);assert.equal(f.c.auditQueue[2].protectedEvidence,undefined);assert.equal(f.c.auditQueue[2].eventId,'interaction');assert.equal(f.writes.length,1);f.c.enforceEvidencePolicy();assert.equal(f.writes.length,1);}); diff --git a/tests/clipboard-review.test.mjs b/tests/clipboard-review.test.mjs index e617062..df57f80 100644 --- a/tests/clipboard-review.test.mjs +++ b/tests/clipboard-review.test.mjs @@ -49,3 +49,13 @@ test('independent clipboard review: selected hidden, inert, script and ancestor- test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{ for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);} }); + +test('clipboard completion metadata follows actual commit and native write, never exposes content',async()=>{ + const frame={},wc={mainFrame:frame,focusedFrame:frame},steps=[],events=[]; + let c={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc}; + const options={getContext:()=>c,stage:async()=>({text:'safe text',target:{tag:'input',x:10,y:20,text:'SECRET',selector:'#secret'}}),validate:async()=>({completed:true}),commit:async()=>{steps.push('commit');return{completed:true};},readClipboard:async()=> 'safe paste',writeClipboard:async()=>steps.push('write'),inspect:async()=>({decision:'allow'}),audit:async(kind,rule,facts)=>{steps.push('audit');assert.equal(JSON.stringify(facts).includes('safe text'),false);},onCompleted:async metadata=>{steps.push('completed');events.push(metadata);}}; + const guard=createClipboardGuard(options);assert.equal((await guard.perform('copy',wc)).completed,true); + assert.deepEqual(steps,['audit','commit','write','completed']);assert.deepEqual(events,[{action:'copy',sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,target:{tag:'input',x:10,y:20},bytes:9}]); + events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0); + options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0); +}); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs index 26de38c..0d83676 100644 --- a/tests/diagnostics.test.mjs +++ b/tests/diagnostics.test.mjs @@ -40,3 +40,15 @@ test('inspector timing batches export exact nested spans only inside live debug }); test('inspector timing validation rejects entire malformed batches and hostile accessors',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const record={id:1,parentId:null,stage:'parse',startOffsetMs:0,durationMs:5,status:'ok'},good=()=>({baseUnixMs:Date.now()-20,records:[record]});const bad=[{...good(),baseUnixMs:Date.now()-61000},{...good(),baseUnixMs:Date.now()+3000},{...good(),records:[record,record]},{...good(),records:[{...record,parentId:2}]},{...good(),records:[{...record,stage:'PASSWORD_SECRET'}]},{...good(),records:[{...record,durationMs:NaN}]},{...good(),records:[{...record,startOffsetMs:29999,durationMs:2}]},{...good(),records:Array(65).fill(record)}];const hostile={};Object.defineProperty(hostile,'baseUnixMs',{get(){throw Error('getter must not run');}});bad.push(hostile);await diagnostics.span('browser.inspect.local',{},async()=>{for(const batch of bad)assert.doesNotThrow(()=>diagnostics.importStages(batch));});await diagnostics.flush();assert.equal(spans(requests).length,1);diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.span('browser.inspect.local',{},async()=>diagnostics.importStages(good()));await diagnostics.flush();assert.equal(spans(requests).length,1); }); + +// Keep operational failure evidence after a centrally requested generation change. +test('export failure totals survive disable and reconfiguration without resending operations',{timeout:10000},async t=>{ + const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());let calls=0; + diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250}); + await diagnostics.span('browser.request',{},async()=>{calls++;return 'allowed';});await diagnostics.flush(); + const failed=diagnostics.status().lifetimeFailedExports;assert.ok(failed>=1); + diagnostics.configure({enabled:false});assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,failed); + await diagnostics.span('browser.request',{},async()=>{calls++;});await diagnostics.flush();assert.equal(requests.length,0); + 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); +}); diff --git a/tests/evidence-channel-server.test.mjs b/tests/evidence-channel-server.test.mjs index 6634661..0fd459d 100644 --- a/tests/evidence-channel-server.test.mjs +++ b/tests/evidence-channel-server.test.mjs @@ -2,10 +2,10 @@ import test from 'node:test';import assert from 'node:assert/strict';import {mkd import channel from '../packages/core/policy-channel.cjs';import browser from '../apps/browser/management-channel.cjs';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; async function fixture(t){ const dir=mkdtempSync(join(tmpdir(),'safetynet-pq-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,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_PQ_EVIDENCE_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_PQ_EVIDENCE_KEY:keyFile},stdio:'pipe'}); - const store=openStore(dir),p=store.get('policy');p.evidence={enabled:true,captureDownloads:true};store.put('policy',p);const token='synthetic-pq-evidence-device-token-123456789',otherToken='synthetic-pq-evidence-other-token-123456789',id='device-a';store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(token));store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run('device-b',digest(otherToken)); + const store=openStore(dir),p=store.get('policy');p.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',p);const token='synthetic-pq-evidence-device-token-123456789',otherToken='synthetic-pq-evidence-other-token-123456789',id='device-a';store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(token));store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run('device-b',digest(otherToken)); const config={publicUrl:'http://127.0.0.1',secure:false,evidenceKeyFile:keyFile,requirePQManagement:true},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();assert.ok(dir.startsWith(join(tmpdir(),'safetynet-pq-evidence-')));rmSync(dir,{recursive:true,force:true});}); const base=`http://127.0.0.1:${server.address().port}`,wire=[];const api=async(route,body,credential=token)=>{wire.push({route,body});const r=await fetch(base+route,{method:body?'POST':'GET',headers:{authorization:`Bearer ${credential}`,...(body?{'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});const payload={eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/file',action:'copy',content:'CONFIDENTIAL_SYNTHETIC_PQ_EVIDENCE',sensitiveField:false}}; + const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});const payload={eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/file',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}}; const device={id,token,server:base,publicKey:store.publicKey(),channelSignatureKey:store.get('channelSigningKeys').publicKey};const make=async()=>{const challenge=await api('/api/device/channel/evidence/challenge',{});assert.equal(challenge.status,200);channel.evidenceChannel.verifyChallenge(challenge.body,id,device.publicKey,device.channelSignatureKey);return {bundle:challenge.body,context:channel.evidenceChannel.prepare(challenge.body,payload)};};return {store,api,wire,payload,device,make}; } test('real central PQ evidence storage rejects plaintext and supports authenticated idempotent retry',async t=>{ @@ -13,9 +13,9 @@ test('real central PQ evidence storage rejects plaintext and supports authentica const request=async(_base,route,body,credential)=>{const response=await f.api(route,body,credential);if(response.status>=400)throw Object.assign(Error('Transport refused'),{status:response.status});return response.body;}; const created=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.match(created.id,/^[0-9a-f-]{36}$/);const again=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.equal(again.id,created.id);assert.equal(again.replayed,true); assert.equal(f.store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(f.store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1); - assert.equal(JSON.stringify(f.wire.filter(r=>r.route.includes('/channel/'))).includes(f.payload.payload.content),false);assert.equal(f.store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(f.payload.payload.content)),false); - const row=f.store.db.prepare('SELECT ciphertext FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(f.payload.payload.content)),false); - await assert.rejects(browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:{...f.payload,payload:{...f.payload.payload,content:'Different synthetic payload'}}}),e=>e.status===409); + assert.equal(JSON.stringify(f.wire.filter(r=>r.route.includes('/channel/'))).includes(f.payload.payload.pageUrl),false);assert.equal(f.store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(f.payload.payload.pageUrl)),false); + const row=f.store.db.prepare('SELECT ciphertext FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(f.payload.payload.pageUrl)),false); + await assert.rejects(browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:{...f.payload,payload:{...f.payload.payload,bytes:17}}}),e=>e.status===409); }); test('challenge consumption covers successful replay, invalid ciphertext and cross-device substitution',async t=>{ const f=await fixture(t);let a=await f.make();assert.equal((await f.api('/api/device/channel/evidence',a.context.request,'synthetic-pq-evidence-other-token-123456789')).status,403); diff --git a/tests/evidence-policy-admin.test.mjs b/tests/evidence-policy-admin.test.mjs index ee7e710..d1b781a 100644 --- a/tests/evidence-policy-admin.test.mjs +++ b/tests/evidence-policy-admin.test.mjs @@ -11,8 +11,8 @@ function fixture({evidence,review=true}={}){ } test('evidence controls default off and preserve unrelated draft fields',()=>{ const {context,get}=fixture();assert.equal(get('evidence-policy-enabled').checked,false);assert.equal(get('evidence-policy-downloads').checked,false); - assert.equal(get('evidence-policy-enabled').disabled,true);assert.equal(get('evidence-policy-downloads').disabled,true); - const draft=context.draft();assert.equal(draft.version,4);assert.equal(draft.brand.name,'Company');assert.equal(draft.evidence.enabled,false);assert.equal(draft.evidence.captureDownloads,false); + assert.equal(get('evidence-policy-enabled').disabled,true);assert.equal(get('evidence-policy-downloads').disabled,true);assert.equal(get('evidence-policy-interactions').disabled,true); + const draft=context.draft();assert.equal(draft.version,4);assert.equal(draft.brand.name,'Company');assert.equal(draft.evidence.enabled,false);assert.equal(draft.evidence.captureDownloads,false);assert.equal(draft.evidence.captureInteractions,false); }); test('independent preparation permission enables draft controls; global off disables child control',()=>{ const {context,get,changes}=fixture({evidence:{enabled:true,captureDownloads:true}}); @@ -20,6 +20,7 @@ test('independent preparation permission enables draft controls; global off disa context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,false);assert.equal(get('evidence-policy-downloads').disabled,false); get('evidence-policy-enabled').checked=false;get('evidence-policy-enabled').change();assert.equal(get('evidence-policy-downloads').disabled,true);assert.equal(context.draft().evidence.enabled,false);assert.equal(changes(),1); context.fill({evidence:{enabled:true,captureDownloads:false}});assert.equal(get('evidence-policy-downloads').checked,false);assert.equal(get('evidence-policy-downloads').disabled,false); + context.fill({evidence:{enabled:true,captureDownloads:false,captureInteractions:true}});assert.equal(get('evidence-policy-interactions').checked,true);assert.equal(get('evidence-policy-interactions').disabled,false);assert.equal(context.draft().evidence.captureInteractions,true);assert.equal(context.draft().evidence.captureDownloads,false); }); test('direct publication mode requires policy.write rather than preparation permission',()=>{ const {context,get}=fixture({review:false});context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,true); diff --git a/tests/evidence-policy.test.mjs b/tests/evidence-policy.test.mjs index 6ddeb9a..f392bf6 100644 --- a/tests/evidence-policy.test.mjs +++ b/tests/evidence-policy.test.mjs @@ -8,7 +8,7 @@ import {mkdtempSync,rmSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; test('evidence controls default off, reject unknown settings and are bound to the policy signature',()=>{ - const p=defaultPolicy();assert.deepEqual(p.evidence,{enabled:false,captureDownloads:false});assert.equal(PolicySchema.safeParse({...p,evidence:{enabled:true,captureDownloads:true,fullFiles:true}}).success,false); + const p=defaultPolicy();assert.deepEqual(p.evidence,{enabled:false,captureDownloads:false,captureInteractions:false});assert.equal(PolicySchema.safeParse({...p,evidence:{enabled:true,captureDownloads:true,fullFiles:true}}).success,false); const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'});p.evidence={enabled:true,captureDownloads:true};const envelope=signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'}));assert.equal(verifyPolicy(envelope,publicKey).evidence.captureDownloads,true);p.evidence.enabled=false;envelope.payload=Buffer.from(JSON.stringify(p)).toString('base64url');assert.throws(()=>verifyPolicy(envelope,publicKey)); }); test('central policy publication cannot enable unconfigured evidence storage',async t=>{ diff --git a/tests/evidence.test.mjs b/tests/evidence.test.mjs index d65ff20..b440638 100644 --- a/tests/evidence.test.mjs +++ b/tests/evidence.test.mjs @@ -1,11 +1,36 @@ -import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,readFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID,createCipheriv} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; function protectFixtureKey(file){ if(process.platform!=='win32')return; const script="$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"; execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',script],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:file},stdio:'pipe'}); } -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};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});return {store,api,id,eventId,config};} -const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic confidential engineering detail',sensitiveField:false}}); +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('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); + p.evidence.captureInteractions=true;p.evidence.captureDownloads=false;store.put('policy',p); + for(const payload of [{...body.payload,sessionId:randomUUID()},{...body.payload,captureSource:'agent-adapter'},{...body.payload,outcome:'authorized'},{...body.payload,content:'any content'},{...body.payload,target:{tag:'BUTTON',label:'any label'}}])assert.equal((await api('/api/device/evidence','POST',{...body,payload})).status,400); + assert.equal((await api('/api/device/evidence','POST',body)).status,201); + for(const [action,outcome]of [['read','completed'],['click','authorized'],['click','failed']]){ + const event=randomUUID();store.event(id,'agent.action',{eventId:event,ruleId:'click:completed',agent:{action,outcome,sessionId:body.payload.sessionId}}); + assert.equal((await api('/api/device/evidence','POST',{eventId:event,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter'}})).status,400); + } + const click=randomUUID();store.event(id,'agent.action',{eventId:click,agent:{action:'click',outcome:'completed',sessionId:body.payload.sessionId}}); + assert.equal((await api('/api/device/evidence','POST',{eventId:click,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter',target:{tag:'BUTTON',role:'button',x:10,y:20}}})).status,201); + const blocked=randomUUID();store.event(id,'clipboard.block',{eventId:blocked,interaction:{action:'copy',outcome:'denied',sessionId:body.payload.sessionId}}); + assert.equal((await api('/api/device/evidence','POST',{...body,eventId:blocked})).status,400); +}); + +test('ordinary interaction audit facts require matching lifecycle and reject content',async t=>{ + const {api,store}=await fixture(t),sessionId=evidence(randomUUID()).payload.sessionId; + const input={eventId:randomUUID(),kind:'clipboard.copy',policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId}}; + assert.equal((await api('/api/device/events','POST',input)).status,200); + for(const interaction of [{...input.interaction,action:'paste'},{...input.interaction,outcome:'denied'},{...input.interaction,content:'secret'}])assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),interaction})).status,400); + assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),kind:'clipboard.block',interaction:{...input.interaction,outcome:'denied'}})).status,200); + const events=store.db.prepare("SELECT metadata FROM events WHERE json_extract(metadata,'$.interaction') IS NOT NULL").all();assert.equal(events.some(row=>row.metadata.includes('secret')),false); +}); test('protected evidence cannot contradict source actions, decisions, hosts or file facts',async t=>{ const {api,store,id,eventId}=await fixture(t); assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,action:'paste'}})).status,400); @@ -22,11 +47,11 @@ test('incident discovery lists only linked metadata without decrypting or releas for(const role of ['operator','auditor'])assert.equal((await api(path,'GET',undefined,role)).status,403); const result=await api(path,'GET',undefined,'admin');assert.equal(result.status,200);assert.equal(result.body.incidentId,incident.body.id); assert.equal((await api(path,'GET',undefined,'analyst')).status,200); - const analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.content,evidence(eventId).payload.content); + const analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.pageUrl,evidence(eventId).payload.pageUrl); const access=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(access.metadata).actor,'analyst'); assert.deepEqual(Object.keys(result.body.evidence[0]).sort(),['id','sourceEventId','eventId','type','createdAt','expiresAt','retentionHold','availability'].sort()); assert.equal(result.body.evidence[0].id,created.body.id);assert.equal(result.body.evidence[0].eventId,eventId);assert.equal(result.body.evidence[0].availability,'available');assert.equal(result.body.evidence[0].retentionHold,true); - assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.content),false);assert.equal(store.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='evidence.accessed'").get().n,1); + assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.pageUrl),false);assert.equal(store.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='evidence.accessed'").get().n,1); // A damaged ciphertext does not prevent safe discovery; reveal still authenticates. store.db.prepare('UPDATE protected_evidence SET ciphertext=?').run(Buffer.from('synthetic corruption')); assert.equal((await api(path,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500); @@ -49,7 +74,7 @@ test('recognizable credentials in free text, target labels, filenames and encode assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0); assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,201); }); -test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.content)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.content,body.payload.content);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.content)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,200);}); +test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.pageUrl)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.pageUrl,body.payload.pageUrl);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.pageUrl)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,200);}); test('unknown credentials, password fields, token fields, unowned events and credential URLs reject',async t=>{const {api,eventId}=await fixture(t);for(const extra of [{password:'synthetic'},{authorization:'Bearer synthetic'},{sensitiveField:true},{pageUrl:'https://user:pass@example.test/'},{pageUrl:'https://example.test/?token=secret'}])assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,...extra}})).status,400);assert.equal((await api('/api/device/evidence','POST',evidence(randomUUID()))).status,404);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),type:'download'})).status,400);}); test('missing key fails closed and ciphertext tampering fails authentication',async t=>{const disabled=await fixture(t,{configured:false});assert.equal((await disabled.api('/api/device/evidence','POST',evidence(disabled.eventId))).status,503);const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE protected_evidence SET expires_at=?').run('2099-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500);}); test('audit failure rolls back evidence and prevents plaintext response',async t=>{const {api,store,eventId}=await fixture(t),emit=store.event;store.event=()=>{throw Error('synthetic audit failure');};assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);store.event=emit;const created=await api('/api/device/evidence','POST',evidence(eventId));store.event=()=>{throw Error('synthetic audit failure');};const got=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin');assert.equal(got.status,500);assert.equal(got.body.payload,undefined);store.event=emit;}); @@ -65,6 +90,18 @@ test('centrally disabled and expired evidence policies reject new capture while }); test('exact evidence replay is idempotent but a changed payload remains a conflict',async t=>{ const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body),replayed=await api('/api/device/evidence','POST',body);assert.equal(replayed.status,200);assert.equal(replayed.body.id,created.body.id);assert.equal(replayed.body.replayed,true); - assert.equal((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,content:'Different synthetic content'}})).status,409); + assert.equal((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,bytes:17}})).status,409); assert.equal(store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1); }); + +// Migration must keep already encrypted, authorized historical records readable. +test('historical interaction content remains readable but cannot be submitted as new capture',async t=>{ + const {api,store,eventId,config}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId)); + const row=store.db.prepare('SELECT * FROM protected_evidence').get(),legacy={pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic historical evidence',sensitiveField:false}; + const key=readFileSync(config.evidenceKeyFile),nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce); + cipher.setAAD(Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]))); + const plain=Buffer.from(JSON.stringify(legacy)),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);key.fill(0); + store.db.prepare('UPDATE protected_evidence SET nonce=?,ciphertext=?,tag=? WHERE id=?').run(nonce,encrypted,cipher.getAuthTag(),row.id); + const revealed=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(revealed.status,200);assert.deepEqual(revealed.body.payload,legacy); + assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:legacy})).status,400); +}); diff --git a/tests/interaction-evidence-delivery.test.mjs b/tests/interaction-evidence-delivery.test.mjs new file mode 100644 index 0000000..c976b7f --- /dev/null +++ b/tests/interaction-evidence-delivery.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +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'; +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']);}); +test('partial disable discards interaction tuples while preserving pending legacy downloads',()=>{const f=fixture();f.context.policy.evidence.captureInteractions=false;assert.equal(collectionAllowed({...f.context,type:'interaction'}),false);assert.equal(collectionAllowed({...f.context,type:'download'}),true);const download={eventId:'download',protectedEvidence:{deviceId:'device-a',payload:{}}};const result=stripPendingEvidence([f.record,download],f.context);assert.equal('protectedEvidence' in result[0],false);assert.equal(result[1],download);assert.ok(f.record.protectedEvidence);}); +test('revocation during ACK never marks evidence delivered and replay preserves original tuple',async()=>{const f=fixture(),snapshot=structuredClone(f.record),bodies=[];const send=async(route,body)=>{bodies.push(body);if(route.endsWith('/evidence')){f.context.policy.evidence.captureInteractions=false;return {id:sessionId,sourceEventId:1};}return {};};assert.deepEqual(await deliverAuditRecord({record:f.record,getContext:()=>f.context,send}),{eventDelivered:true,evidenceDiscarded:true});assert.deepEqual(f.record,snapshot);assert.equal(bodies[1].type,'interaction');assert.equal('protectedEvidence' in bodies[0],false);}); +test('native hook target tags are canonicalized without accepting labels or arbitrary DOM data',()=>{const f=fixture(),base={action:'click',sessionId,url:'https://example.com/page',sensitiveField:false,outcome:'completed',captureSource:'agent-adapter'};for(const tag of ['a','button','input','textarea','select','div','span','p','body','unknown']){const value=buildInteractionEvidence({...base,target:{tag,role:'menuitem',x:12,y:34}},f.context.policy);assert.equal(value.target.tag,tag.toUpperCase());assert.equal(value.target.role,'menuitem');assert.deepEqual([value.target.x,value.target.y],[12,34]);}for(const target of [{tag:'button',label:'secret'},{tag:'a',selector:'#secret'},{tag:'body',text:'secret'},{tag:'script'},{tag:'button',x:Infinity},{tag:'button',y:-1}])assert.equal(buildInteractionEvidence({...base,target},f.context.policy),null);assert.equal(buildInteractionEvidence({...base,target:{tag:'select',role:'option'}},f.context.policy).target.role,'option');}); diff --git a/tests/interaction-siem.test.mjs b/tests/interaction-siem.test.mjs new file mode 100644 index 0000000..3627a51 --- /dev/null +++ b/tests/interaction-siem.test.mjs @@ -0,0 +1,7 @@ +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'; +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/native-diagnostics-attribution.test.mjs b/tests/native-diagnostics-attribution.test.mjs new file mode 100644 index 0000000..0b1bbe7 --- /dev/null +++ b/tests/native-diagnostics-attribution.test.mjs @@ -0,0 +1,16 @@ +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('../scripts/native-tabs-smoke.mjs',import.meta.url),'utf8'),c=vm.createContext({}); +vm.runInContext(source.slice(source.indexOf('function exportCountsByService'),source.indexOf('const diagnosticStatus')),c); +const resource=name=>({resource:{attributes:[{key:'service.name',value:{stringValue:name}}]}}); +test('shared native receiver distinguishes actual browser exports from backend batches',()=>{ + const records=[{payload:{resourceSpans:[resource('safetynet-browser')]}},{payload:{resourceLogs:[resource('safetynet-browser')]}},{payload:{resourceSpans:[resource('safetynet-control-plane')]}}]; + assert.deepEqual({...c.exportCountsByService(records)},{browser:2,controlPlane:1,unknown:0}); + records.push({payload:{resourceSpans:[resource('safetynet-control-plane')]}});assert.deepEqual({...c.exportCountsByService(records)},{browser:2,controlPlane:2,unknown:0}); + records.push({payload:{resourceMetrics:[resource('safetynet-browser'),resource('safetynet-browser')]}});assert.equal(c.exportCountsByService(records).browser,3,'Count transport requests, not resource groups'); +}); +test('unattributed and mixed batches cannot disappear from collector accounting',()=>{ + assert.deepEqual({...c.exportCountsByService([{payload:{}},{payload:{resourceSpans:[resource('unexpected')]}},{payload:{resourceSpans:[resource('safetynet-browser'),resource('safetynet-control-plane')]}}])},{browser:1,controlPlane:1,unknown:2}); +}); diff --git a/tests/policy-stage-diagnostics.test.mjs b/tests/policy-stage-diagnostics.test.mjs new file mode 100644 index 0000000..aa9695f --- /dev/null +++ b/tests/policy-stage-diagnostics.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {generateKeyPairSync} from 'node:crypto'; +import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs'; +import {signBookmarkSnapshot,verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs'; +import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs'; +const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'); +const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'); +// Substitute only the dynamic module-loader boundary; execute the actual shipped +// application function and the real OTLP diagnostics helper. +const extracted=source.slice(source.indexOf('function applyPolicy(envelope)'),source.indexOf('async function sync()')).replace("import('../../packages/core/bookmarks.mjs')","Promise.resolve(bookmarkModule)"); +async function fixture(t){ + const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve)); + const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}); + t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config); + const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001'; + const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)}); + const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});}; + const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')}); + vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])}; +} +test('actual unchanged policy exports correlated verification, bookmark and agent child stages',async t=>{ + const f=await fixture(t);await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();const spans=f.spans(),parent=spans.find(s=>!s.parentSpanId);assert.equal(parent.name,'browser.policy'); + for(const operation of ['policy.verify','policy.bookmarks','policy.agent-reconcile']){const child=spans.find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(child,operation);assert.equal(child.traceId,parent.traceId);assert.equal(child.parentSpanId,parent.spanId);assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));} + assert.equal(JSON.stringify(f.payloads).includes('fixture'),false); +}); +test('new signed policy disables diagnostics throughout network transition and honors final disable',async t=>{ + const f=await fixture(t);f.c.policy={...f.next,version:0};f.next.diagnostics={enabled:false};await f.c.applyPolicy(f.envelope());await f.diagnostics.flush(); + for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,false,name); + assert.deepEqual(f.calls.filter(v=>v.name==='configure').map(v=>v.enabled),[false,false]);assert.equal(f.diagnostics.status().enabled,false);assert.equal(f.spans().length,0,'Retired old generation must not export'); +}); +test('enabled new policy exports only post-configuration stages after protected network transition',async t=>{ + const f=await fixture(t);f.c.policy={...f.next,version:0};await f.c.applyPolicy(f.envelope());await f.diagnostics.flush(); + assert.equal(f.calls.find(v=>v.name==='browser-proxy').enabled,false);assert.equal(f.calls.find(v=>v.name==='page').enabled,true); + const operations=f.spans().map(s=>s.attributes.find(a=>a.key==='safetynet.operation')?.value.stringValue);assert.deepEqual(operations.sort(),['policy.page-configure','policy.agent-reconcile','policy.audit'].sort()); +}); +test('signed verification failure prevents bookmark, proxy and policy mutations',async t=>{ + const f=await fixture(t);const tampered=f.envelope();tampered.payload+='A';await assert.rejects(f.c.applyPolicy(tampered),/signature rejected/);await f.diagnostics.flush();assert.equal(f.calls.length,0);assert.equal(f.c.policy.version,1);assert.ok(f.spans().some(s=>s.status.code===2)); +});