Capture guarded human gestures and verify agent administration

This commit is contained in:
Karl committed 2026-10-13 18:24:44 +02:00
1 parent 244f01630b
commit adf0940e5f
32 files changed
+523 -52

No files matched your search

@@ -22,3 +22,8 @@ for(const part of ['baseline','group','profile'])test(`truncated ${part} diff ne
});
test('missing user-profile diff fails closed rather than treating it as unchanged',async()=>{const comparison=empty();delete comparison.profileDiff;const f=fixture(comparison);await assert.rejects(f.review(),/Complete baseline, group and user-profile/);assert.equal(f.get('history-review').hidden,true);assert.equal(vm.runInContext('historyReview',f.context),null);});
test('profile values are rendered as literal text and read-only identities cannot approve',async()=>{const comparison=empty();comparison.profileDiff.changes=[{path:'/profiles/0/name',kind:'changed',before:'<img src=x onerror=alert(1)>',after:'Current'}];const f=fixture(comparison,['policy.read']);await f.review();assert.match(f.get('history-diff-list').children[0].children[1].textContent,/<img src=x onerror=alert\(1\)>/);assert.equal(f.get('history-approve').disabled,true);assert.equal(f.get('history-restore').disabled,true);});
for(const [part,path] of [['baseline','/agents/grants'],['group','/0/policy/agents/grants'],['profile','/profiles/0/policy/agents/grants']])test(`grant ${part} restoration explains persistent generation floors before confirmation`,async()=>{
const comparison=empty(),change={path,kind:'changed',before:[{id:'research-agent',generation:1}],after:[{id:'research-agent',generation:2}]};(part==='baseline'?comparison.changes:comparison[`${part}Diff`].changes).push(change);const f=fixture(comparison);await f.review();assert.match(f.get('history-review-summary').textContent,/highest observed credential generation/);assert.match(f.get('history-review-summary').textContent,/does not restore invalidated credentials/);assert.match(f.get('history-review-summary').textContent,/greater than every previously issued generation/);assert.equal(f.get('history-restore').disabled,true);
});
test('unrelated restoration does not imply a credential-generation change',async()=>{const comparison=empty();comparison.changes=[{path:'/brand/name',kind:'changed',before:'Previous',after:'Current'}];const f=fixture(comparison);await f.review();assert.doesNotMatch(f.get('history-review-summary').textContent,/Agent credential warning/);});
+27
View File
@@ -52,3 +52,30 @@ test('export failure totals survive disable and reconfiguration without resendin
diagnostics.configure(config(endpoint));await diagnostics.span('browser.request',{},async()=>{calls++;});await diagnostics.flush();
assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,failed);assert.equal(calls,3);assert.equal(spans(requests).length,1);
});
test('one asynchronous audit flusher resumes each persisted source parent in real OTLP', {timeout:15000},async t=>{
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const rows=[];let calls=0;
await diagnostics.span('browser.clipboard',{operation:'copy'},async()=>rows.push({...diagnostics.correlation(),secret:'PERSISTED_SECRET'}));
await diagnostics.span('browser.agent',{operation:'click'},async()=>rows.push({...diagnostics.correlation()}));
// The flusher retains an unrelated span; neither delivery may adopt it.
await diagnostics.span('browser.management',{operation:'sync'},async()=>{
for(const row of rows)await diagnostics.withCorrelation(row,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{
calls++;await delay(2);await diagnostics.span('browser.request',{operation:'request'},async()=>{});
}));
});await diagnostics.flush();const output=spans(requests),deliveries=output.filter(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue==='audit.deliver'));
assert.equal(calls,2);assert.equal(deliveries.length,2);for(const row of rows){const delivery=deliveries.find(s=>s.traceId===row.traceId);assert.ok(delivery);assert.equal(delivery.parentSpanId,row.spanId);assert.ok(output.some(s=>s.name==='browser.request'&&s.traceId===row.traceId&&s.parentSpanId===delivery.spanId));}
assert.equal(JSON.stringify(requests).includes('PERSISTED_SECRET'),false);assert.equal(output.length,7);
});
test('invalid persisted IDs clear unrelated parents without getters, retries or disabled exports',{timeout:15000},async t=>{
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const good={traceId:'123456789abcdef0123456789abcdef0',spanId:'123456789abcdef0'};let calls=0,getters=0;
const accessor={spanId:good.spanId};Object.defineProperty(accessor,'traceId',{get(){getters++;throw Error('Never invoke');}});
const invalid=[null,[],accessor,{...good,traceId:'0'.repeat(32)},{...good,spanId:'0'.repeat(16)},{...good,traceId:good.traceId.toUpperCase()},{...good,traceId:good.traceId+'x'},Object.create(good),new Proxy({},{ownKeys(){throw Error('Refuse metadata');}})];
let unrelated;await diagnostics.span('browser.agent',{operation:'click'},async()=>{
unrelated=diagnostics.correlation();for(const ids of invalid)await diagnostics.withCorrelation(ids,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));
});await diagnostics.flush();assert.equal(getters,0);assert.equal(calls,invalid.length);for(const item of spans(requests).filter(s=>s.name==='browser.management')){assert.notEqual(item.traceId,unrelated.traceId);assert.ok(!item.parentSpanId);}
const failure=Error('Operation failed');assert.throws(()=>diagnostics.withCorrelation(good,()=>{calls++;throw failure;}),error=>error===failure);
const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);
diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3);
});
+12
View File
@@ -7,6 +7,18 @@ function protectFixtureKey(file){
async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});return {store,api,id,eventId,config};}
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}});
test('human observations bind exact event action and session without claiming transfer completion',async t=>{
const {api,store,id}=await fixture(t),sessionId=randomUUID();
for(const action of ['click','submit']){
const eventId=randomUUID(),payload={pageUrl:'https://intranet.example/',action,sessionId,outcome:'observed',captureSource:'human-observer',sensitiveField:false,target:{tag:'BUTTON'}};
assert.equal((await api('/api/device/events','POST',{eventId,kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome:'observed'}})).status,200);
for(const outcome of ['completed','authorized','denied'])assert.equal((await api('/api/device/events','POST',{eventId:randomUUID(),kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome}})).status,400);
for(const extra of [{action:action==='click'?'submit':'click'},{outcome:'completed'},{sessionId:randomUUID()},{captureSource:'agent-adapter'},{bytes:1},{content:'PRIVATE_BODY'}])assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:{...payload,...extra}})).status,400);
const accepted=await api('/api/device/evidence','POST',{eventId,type:'interaction',payload});assert.equal(accepted.status,201);
assert.deepEqual((await api(`/api/admin/evidence/${accepted.body.id}?reason=investigation`,'GET',undefined,'analyst')).body.payload,payload);
}
});
test('interaction collection is independent and binds completed action, session and capture source',async t=>{
const {api,store,id,eventId}=await fixture(t),body=evidence(eventId),p=store.get('policy');
p.evidence.captureInteractions=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
function fixture(){
const contents={isDestroyed:()=>false,getURL:()=> 'https://example.test/page'},tab={id:'tab-1',contents,startPage:false,security:{interactionDocumentGeneration:2}},live={navigationId:8,activation:4,paused:false},policy={version:3,expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}};
const c=vm.createContext({ready:true,policy,activeTab:()=>tab,tabs:{context:()=>({...live})},tabContext:()=>({...live}),currentTabContext:()=>true,overviewPanel:false,toolsOpen:false});
vm.runInContext(source.slice(source.indexOf('function interactionObserverContext(tab)'),source.indexOf('function installSiteHooks(tab)')),c);
return {c,tab,live,contents,context:c.interactionObserverContext(tab)};
}
test('human observation survives visual DOM revisions but rejects real document or activation changes',()=>{
const f=fixture();assert.equal(f.c.currentInteractionObserverContext(f.context),true);f.live.navigationId++;assert.equal(f.c.currentInteractionObserverContext(f.context),true,'Visual reinspection does not imply another document');f.tab.security.interactionDocumentGeneration++;assert.equal(f.c.currentInteractionObserverContext(f.context),false);f.tab.security.interactionDocumentGeneration--;f.live.activation++;assert.equal(f.c.currentInteractionObserverContext(f.context),false);
});
test('human observation rejects revocation, disabled capture, expiry, overlays and changed native contents',()=>{
for(const mutate of [f=>f.c.ready=false,f=>f.c.policy.evidence.captureInteractions=false,f=>f.c.policy.version++,f=>f.c.policy.expiresAt=new Date(0).toISOString(),f=>f.c.toolsOpen=true,f=>f.c.overviewPanel=true,f=>f.tab.startPage=true,f=>f.tab.contents={...f.contents},f=>f.contents.getURL=()=> 'https://example.test/other',f=>f.live.paused=true]){const f=fixture();mutate(f);assert.equal(f.c.currentInteractionObserverContext(f.context),false);}
});
+82
View File
@@ -0,0 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {createRequire} from 'node:module';
const {humanInteractionScript}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
const token='a'.repeat(32),url='https://intranet.example/path?private=1';
function fixture(){
let time=1000;const listeners=new Map();const win={addEventListener:(type,fn)=>listeners.set(type,fn),removeEventListener:(type,fn)=>{if(listeners.get(type)===fn)listeners.delete(type);}};win.top=win;
const context=vm.createContext({window:win,location:{href:url},innerWidth:1000,innerHeight:800,navigator:{userActivation:{isActive:true}},Date:{now:()=>time}});
const run=mode=>vm.runInContext(humanInteractionScript(mode,{url,token}),context);
const target=(tag='BUTTON',attrs={})=>({nodeType:1,tagName:tag,isConnected:true,parentElement:null,type:'button',hasAttribute:key=>Object.hasOwn(attrs,key),getAttribute:key=>attrs[key]||null,closest:()=>null});
const emit=(type,element=target(),extra={})=>listeners.get(type)?.({type,target:element,isTrusted:true,clientX:12.2,clientY:31.8,...extra});
return {run,target,emit,listeners,context,advance:ms=>{time+=ms;}};
}
test('trusted observation exports structural metadata only, rejects synthetic events',()=>{
const f=fixture();assert.equal(f.run('install').installed,true);
f.emit('click',f.target('BUTTON',{'aria-label':'Confidential deal','data-value':'sensitive'}),{isTrusted:false});
assert.equal(f.run('drain').events.length,0);
f.emit('click',f.target('BUTTON',{'aria-label':'ordinary button','id':'id-private','data-value':'sensitive'}));
const result=JSON.parse(JSON.stringify(f.run('drain')));
assert.deepEqual(result.events,[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON',x:12,y:32}}]);
assert.equal(JSON.stringify(result).includes('id-private'),false);
});
test('password, credential ancestors and sensitive forms never contribute metadata',()=>{
const f=fixture();f.run('install');const password=f.target('INPUT');password.type='password';
f.emit('click',password);const child=f.target();child.parentElement=f.target('DIV',{'data-sensitive':''});f.emit('click',child);
const form=f.target('FORM');form.elements=[password];f.emit('submit',form);const button=f.target();button.form=form;f.emit('click',button);
assert.equal(f.run('drain').events.length,0);
});
test('submit is observed, not evidence of transmission, and carries no fields',()=>{
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[f.target('INPUT')];form.elements[0].value='company confidential';
f.emit('keydown',form.elements[0],{key:'Enter'});
f.emit('submit',form,{defaultPrevented:true});
assert.deepEqual(JSON.parse(JSON.stringify(f.run('drain').events)),[{action:'submit',outcome:'observed',sensitiveField:false,target:{tag:'OTHER'}}]);
});
test('queue and rate are bounded, lease expiry and navigation prevent new capture',()=>{
const f=fixture();f.run('install');for(let i=0;i<1000;i++)f.emit('click');
let result=f.run('drain');assert.equal(result.events.length,16);assert.equal(result.dropped,984);
for(let i=0;i<100;i++)f.emit('click');assert.equal(f.run('drain').events.length,4);
f.advance(1600);f.emit('click');assert.equal(f.run('drain').events.length,0);
f.context.location.href='https://other.example/';f.emit('click');assert.equal(f.run('drain'),null);
});
test('replacement and dispose remove listeners and pending metadata',()=>{
const f=fixture();f.run('install');f.emit('click');f.run('install');assert.equal(f.run('drain').events.length,0);
f.run('dispose');assert.equal(f.listeners.size,0);assert.equal(f.run('drain'),null);
assert.throws(()=>humanInteractionScript('install',{url,token:'untrusted'}));
});
const {createHumanInteractionObserver}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
function controllerFixture(){
const dom=fixture();const contents={isDestroyed:()=>false};let context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:contents};const seen=[];
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:s=>context.ready&&context.enabled&&s.token===context.token&&s.policyVersion===context.policyVersion&&s.foreground===context.foreground,executeScript:async(_contents,code)=>vm.runInContext(code,dom.context),onObserved:async event=>seen.push(event)});
return {dom,observer,seen,set:update=>{context={...context,...update};}};
}
test('controller binds current navigation and policy and drops queued stale metadata',async()=>{
const f=controllerFixture();try{
await f.observer.poll();f.dom.emit('click');await f.observer.poll();assert.equal(f.seen.length,1);
f.dom.emit('click');f.set({token:'nav-2'});await f.observer.poll();await f.observer.poll();assert.equal(f.seen.length,1);
f.dom.emit('click');f.set({enabled:false});await f.observer.poll();assert.equal(f.dom.listeners.size,0);assert.equal(f.seen.length,1);
}finally{f.observer.dispose();}
});
test('refresh and dispose invalidate in-flight batches without replay',async()=>{
let resolve;const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};const seen=[];let calls=0;
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};return new Promise(r=>{resolve=r;});},onObserved:async value=>seen.push(value)});
try{await observer.poll();const pending=observer.poll();observer.refresh();resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
});
test('submit without native user activation is excluded',()=>{
const f=fixture();f.run('install');f.context.navigator.userActivation.isActive=false;const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
});
test('late DOM drain remains serialized and expires without invoking audit',async()=>{
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let resolve,calls=0;const seen=[];
const observer=createHumanInteractionObserver({deadline:100,getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};if(calls===2)return new Promise(r=>{resolve=r;});return null;},onObserved:async event=>seen.push(event)});
try{await observer.poll();const pending=observer.poll();await new Promise(r=>setTimeout(r,120));await observer.poll();assert.equal(calls,2);resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
});
test('destroyed renderer disposal errors never escape lifecycle callback',async()=>{
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let installed=false;
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:()=>{if(!installed){installed=true;return {installed:true};}throw Error('Destroyed renderer');},onObserved:()=>{}});
await observer.poll();assert.doesNotThrow(()=>observer.dispose());await new Promise(r=>setImmediate(r));
});
test('trusted programmatic submit without recent click or Enter is excluded',()=>{
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
f.emit('keydown',form,{key:'Enter'});f.advance(1100);f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
});
@@ -5,6 +5,17 @@ const require=createRequire(import.meta.url);
const {buildInteractionEvidence,validateInteractionEvidence}=require('../apps/browser/evidence-capture.cjs');
const {collectionAllowed,stripPendingEvidence,deliverAuditRecord,validatedEvidence}=require('../apps/browser/evidence-delivery.cjs');
const sessionId='00000000-0000-4000-8000-000000000001';
test('human-observer accepts observed click/submit only and uses its own audit source',async()=>{
const policy={expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}},context={ready:true,deviceId:'device-a',policy};
for(const action of ['click','submit']){
const payload=buildInteractionEvidence({action,sessionId,url:'https://example.com/path?secret=1',sensitiveField:false,outcome:'observed',captureSource:'human-observer'},policy);
assert.equal(payload.pageUrl,'https://example.com/');assert.equal(payload.outcome,'observed');
for(const extra of [{outcome:'completed'},{action:'copy'},{bytes:1},{captureSource:'agent-adapter'}])assert.equal(validateInteractionEvidence({...payload,...extra}),false);
const record={kind:'browser.interaction',eventId:sessionId,protectedEvidence:{deviceId:'device-a',type:'interaction',payload}},routes=[];
assert.equal((await deliverAuditRecord({record,getContext:()=>context,send:async route=>{routes.push(route);return {id:sessionId,sourceEventId:1};}})).evidenceDelivered,true);
assert.deepEqual(routes,['/api/device/events','/api/device/evidence']);record.kind='agent.action';await assert.rejects(deliverAuditRecord({record,getContext:()=>context,send:async()=>({})}),/source/);
}
});
function fixture(){const context={ready:true,deviceId:'device-a',policy:{expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true,captureInteractions:true}}};const payload=buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com/private/password=secret?q=secret#secret',sensitiveField:false,outcome:'completed',captureSource:'guarded-clipboard',bytes:12},context.policy);const record={eventId:sessionId,kind:'clipboard.copy',protectedEvidence:{deviceId:'device-a',type:'interaction',payload}};return {context,payload,record};}
test('automatic interaction capture requires observed completion and excludes path and sensitive data',()=>{const f=fixture();assert.equal(f.payload.pageUrl,'https://example.com/');assert.equal(validateInteractionEvidence(f.payload),true);for(const extra of [{content:'secret'},{contentSha256:'a'.repeat(64)},{target:{tag:'INPUT',label:'secret'}},{sensitiveField:true},{outcome:'authorized'},{captureSource:'page'},{bytes:16777217}])assert.equal(validateInteractionEvidence({...f.payload,...extra}),false);assert.equal(buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com',sensitiveField:false,outcome:'authorized',captureSource:'guarded-clipboard'},f.context.policy),null);});
test('type binding rejects cross-type payloads without attempting evidence send',async()=>{const f=fixture();assert.throws(()=>validatedEvidence(f.payload,'download'),/Invalid/);f.record.protectedEvidence.type='download';const calls=[];await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{calls.push(route);return {};}}),/Invalid/);assert.deepEqual(calls,['/api/device/events']);});
+5
View File
@@ -2,6 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {toECS,toCEF} from '../packages/core/siem.mjs';
const sessionId='00000000-0000-4000-8000-000000000001';
test('observed browser gestures export as information with unknown outcome',()=>{
const e={id:2,time:'2026-10-13T12:00:00Z',kind:'browser.interaction',metadata:{interaction:{action:'submit',outcome:'observed',sessionId}}};
const ecs=toECS(e);assert.deepEqual(ecs.event.type,['info']);assert.equal(ecs.event.outcome,'unknown');assert.equal(ecs.safetynet.interaction.outcome,'observed');assert.ok(toCEF(e).includes('cs3=observed'));
for(const outcome of ['completed','authorized','denied']){e.metadata.interaction.outcome=outcome;assert.equal(toECS(e).safetynet.interaction,undefined);}
});
function record(outcome){return {id:1,time:'2026-10-13T12:00:00Z',kind:outcome==='denied'?'clipboard.block':'clipboard.copy',device_id:'device-a',metadata:{interaction:{action:'copy',outcome,sessionId},ruleId:'clipboard-policy',policyVersion:2,traceId:'1'.repeat(32),spanId:'2'.repeat(16)},audit:{hash:'3'.repeat(64),sequence:1}};}
test('ECS and CEF retain interaction phase/session while preserving trace and audit references',()=>{for(const outcome of ['authorized','completed','denied']){const e=record(outcome),ecs=toECS(e),cef=toCEF(e);assert.deepEqual(ecs.safetynet.interaction,{action:'copy',outcome,session_id:sessionId});assert.equal(ecs.trace.id,'1'.repeat(32));assert.ok(cef.includes('cs2Label=InteractionAction cs2=copy'));assert.ok(cef.includes('cs3Label=InteractionOutcome cs3='+outcome));assert.ok(cef.includes('requestContext='+sessionId));assert.ok(cef.includes('flexString1Label=AuditRecordHash'));assert.ok(cef.includes('cs4Label=TraceId'));}});
test('malformed or contradictory interaction metadata never enters security exports',()=>{for(const value of [{action:'paste',outcome:'completed',sessionId},{action:'copy',outcome:'completed',sessionId,content:'PRIVATE_BODY'},{action:'copy',outcome:'completed',sessionId:'private-session'}]){const e=record('completed');e.metadata.interaction=value;assert.equal(toECS(e).safetynet.interaction,undefined);assert.equal(toCEF(e).includes('InteractionAction'),false);assert.equal(JSON.stringify(toECS(e)).includes('PRIVATE_BODY'),false);}});
+15
View File
@@ -13,3 +13,18 @@ test('quarantine stops hidden and visible tabs, invalidates approvals and denies
test('close destroys native contents and cannot revive closed tab capabilities',()=>{const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context();tabs.remove(a.id);assert.equal(a.view.webContents.destroyed,true);assert.equal(tabs.current(token),false);assert.equal(tabs.active(),b);assert.equal(tabs.fromContents(a.view.webContents.id),null);tabs.close();assert.equal(b.view.webContents.destroyed,true);assert.equal(disposed.length,2);assert.throws(()=>tabs.create());});
test('URL mutation and renderer-chosen identifiers cannot reuse native approval',()=>{const {tabs}=fixture();const a=tabs.create();tabs.resume(1);const token=tabs.context();a.view.webContents.url='https://other.test/';assert.equal(tabs.current(token),false);assert.throws(()=>tabs.activate('renderer-forged-id'));assert.equal(Object.isFrozen(token),true);assert.notEqual(a.id,'managed');});
test('capacity is bounded before another renderer process is created',()=>{const {tabs}=fixture({maxTabs:1});tabs.create();assert.throws(()=>tabs.create(),/capacity/);assert.equal(tabs.all().length,1);});
// Electron can remove view.webContents before pending startup rejects. The
// native reference must survive solely for deterministic cleanup/revocation.
test('destroyed native view remains removable after its live getter disappears',()=>{
const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id),contents=a.contents;
assert.throws(()=>{a.contents=b.contents;},TypeError);
assert.equal(Object.keys(a).includes('contents'),false);
contents.destroyed=true;delete a.view.webContents;
assert.equal(tabs.current(token),false);assert.throws(()=>tabs.context(a.id),/Unknown native tab/);
assert.doesNotThrow(()=>tabs.remove(a.id));assert.equal(tabs.fromContents(contents.id),null);assert.equal(disposed.includes(a),true);assert.equal(tabs.active(),b);
});
test('shutdown cleans already destroyed views without reactivating surviving views',()=>{
const {tabs,disposed,attached}=fixture();const a=tabs.create(),b=tabs.create();a.contents.destroyed=true;delete a.view.webContents;
const attachments=attached.length;assert.doesNotThrow(()=>tabs.close());assert.equal(disposed.length,2);assert.equal(tabs.all().length,0);assert.equal(attached.length,attachments);assert.equal(b.contents.destroyed,true);assert.doesNotThrow(()=>tabs.close());
});