Add protected interaction evidence and measured debugging phases
This commit is contained in:
1 parent
ea448de2d4
commit
244f01630b
42 files changed
+640
-87
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=>{
|
||||
|
||||
+44
-7
@@ -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:[email protected]/'},{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);
|
||||
});
|
||||
@@ -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');});
|
||||
@@ -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);}});
|
||||
@@ -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});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user