Implement managed browser security, individual agents and observability
This commit is contained in:
1 parent
ff482ebe23
commit
ea448de2d4
172 files changed
+5404
-347
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
|
||||
const html=readFileSync(new URL('../apps/server/public/index.html',import.meta.url),'utf8');
|
||||
test('every centrally offered agent scope has an accessible structured control',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of scopes)assert.ok(html.includes(`id="agent-scope-${scope}"`),scope);for(const scope of ['inspect','scroll','library:organize-plan','library:organize-apply'])assert.ok(scopes.includes(scope));});
|
||||
test('diagnostic admin roundtrip retains bounded export settings and denies auditors',()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,addEventListener(){}});return fields.get(id);};let p={diagnostics:DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:'https://collector.example',maxQueueSize:123,flushIntervalMs:333,maxRecordAgeMs:4567,exportTimeoutMs:999})};const context=vm.createContext({$:get,fill(){},draft:()=>structuredClone(p),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null,updateDraftStatus(){}});vm.runInContext(readFileSync(new URL('../apps/server/public/diagnostics-admin.js',import.meta.url),'utf8'),context);context.fill(p);assert.deepEqual(DiagnosticsSchema.parse(context.draft().diagnostics),p.diagnostics);context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('diagnostics-enabled').disabled,true);context.applyOperatorPermissions({role:'operator',permissions:['policy.prepare']});assert.equal(get('diagnostics-enabled').disabled,false);get('diagnostics-enabled').checked=false;assert.equal(DiagnosticsSchema.parse(context.draft().diagnostics).enabled,false);});
|
||||
test('SIEM control is administrator-only and writes the loaded revision',async()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,hidden:false,textContent:'',addEventListener(type,fn){this.listener=fn;}});return fields.get(id);};const calls=[],context=vm.createContext({$:get,applyOperatorPermissions(){},load:async()=>{},task:fn=>fn,currentPrincipal:null,api:async(path,body,method)=>{calls.push({path,body,method});return {enabled:body?.enabled??true,revision:8,format:'ecs',pending:2,failures:0,configured:true};}});vm.runInContext(readFileSync(new URL('../apps/server/public/siem-admin.js',import.meta.url),'utf8'),context);context.currentPrincipal={role:'auditor',permissions:['policy.read']};context.applyOperatorPermissions(context.currentPrincipal);assert.equal(get('siem-controls').hidden,true);assert.equal(calls.length,0);await assert.rejects(get('siem-save').listener(),/Administrator/);context.currentPrincipal={role:'admin',permissions:['policy.read']};context.fillSIEMExport({enabled:true,revision:7,format:'cef'});context.applyOperatorPermissions(context.currentPrincipal);get('siem-enabled').checked=false;await get('siem-save').listener();assert.equal(calls[0].path,'/api/admin/siem');assert.equal(calls[0].method,'PUT');assert.equal(calls[0].body.enabled,false);assert.equal(calls[0].body.expectedRevision,7);});
|
||||
|
||||
import {FileTransfersSchema} from '../packages/core/file-transfer.mjs';
|
||||
test('download scan settings remain explicit and cannot alter upload policy',()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{checked:false,disabled:false});return fields.get(id);};const policy={fileTransfers:FileTransfersSchema.parse({upload:{blockUnknown:true},download:{}}),security:{yara:{enabled:true,rules:[],scanOfficeMacros:true}}};const context=vm.createContext({$:get,fill(){},draft:()=>structuredClone(policy),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null});vm.runInContext(readFileSync(new URL('../apps/server/public/download-scan-admin.js',import.meta.url),'utf8'),context);context.fill(policy);assert.equal(get('download-document-dlp').checked,true);get('download-document-dlp').checked=false;get('yara-office-macros').checked=false;const updated=context.draft();assert.equal(FileTransfersSchema.parse(updated.fileTransfers).download.requireDocumentDlp,false);assert.deepEqual(updated.fileTransfers.upload,policy.fileTransfers.upload);assert.equal(updated.security.yara.scanOfficeMacros,false);context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('yara-office-macros').disabled,true);});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
// Execute the shipped history review code against a minimal DOM, not a duplicate implementation.
|
||||
const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8');
|
||||
const history=source.slice(source.indexOf('let historyNextBefore='),source.indexOf("$('history-load').onclick="));
|
||||
function fixture(comparison,permissions=['policy.write']){
|
||||
const fields=new Map(),element=()=>({children:[],hidden:false,checked:false,disabled:false,textContent:'',append(...items){this.children.push(...items);},replaceChildren(...items){this.children=items;},scrollIntoView(){}}),get=id=>{if(!fields.has(id))fields.set(id,element());return fields.get(id);};
|
||||
const context=vm.createContext({$:get,document:{createElement:element},publishedSnapshot:{version:5},operatorPermissions:new Set(permissions),independentApprovalRequired:false,api:async path=>path.startsWith('/api/admin/policy/diff')?comparison:{currentVersion:5},cell(){},task:fn=>fn});
|
||||
vm.runInContext(history,context);
|
||||
return {context,get,review:()=>context.reviewHistory(3)};
|
||||
}
|
||||
const empty=()=>({changes:[],truncated:false,groupDiff:{changes:[],truncated:false},profileDiff:{changes:[],truncated:false}});
|
||||
test('profile-only restore displays assignments, grants and categories even when baseline/groups unchanged',async()=>{
|
||||
const comparison=empty();comparison.profileDiff.changes=[{path:'/profiles/0/subjects',kind:'changed',before:['verified-admin'],after:['verified-user']},{path:'/profiles/0/deviceIds',kind:'changed',before:['one'],after:['two']},{path:'/profiles/0/features/devTools',kind:'changed',before:'allow',after:'deny'},{path:'/categories/0/hosts',kind:'changed',before:['*.company.example'],after:['wiki.company.example']}];
|
||||
const f=fixture(comparison);await f.review();const rows=f.get('history-diff-list').children;
|
||||
assert.equal(rows.length,4);assert.match(rows[0].children[0].textContent,/^\/userProfiles\/profiles\/0\/subjects/);assert.match(rows[0].children[1].textContent,/Selected version:.*verified-admin/);assert.match(rows[2].children[1].textContent,/Selected version: "allow"/);assert.match(f.get('history-review-summary').textContent,/0 baseline paths, 0 group paths and 4 user-profile/);assert.match(f.get('history-review-summary').textContent,/developer-tool grants/);assert.equal(f.get('history-review').hidden,false);assert.equal(f.get('history-approve').disabled,false);assert.equal(f.get('history-restore').disabled,true);
|
||||
});
|
||||
for(const part of ['baseline','group','profile'])test(`truncated ${part} diff never arms restoration`,async()=>{
|
||||
const comparison=empty();if(part==='baseline')comparison.truncated=true;else comparison[`${part}Diff`].truncated=true;const f=fixture(comparison);await assert.rejects(f.review(),/supported review size/);assert.equal(f.get('history-review').hidden,true);assert.equal(f.get('history-restore').disabled,true);assert.equal(vm.runInContext('historyReview',f.context),null);
|
||||
});
|
||||
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);});
|
||||
@@ -172,3 +172,135 @@ test('whole-application policy capabilities are opt-in and unknown scopes are re
|
||||
assert.deepEqual(AgentsSchema.parse({enabled:true,scopes}).scopes,scopes);
|
||||
assert.throws(()=>AgentsSchema.parse({enabled:true,scopes:['policy:override']}));
|
||||
});
|
||||
|
||||
test('structured agent inspection classifies field values and rejects policy races',async()=>{
|
||||
const f=fixture();f.context.policy.agents.scopes.push('inspect','scroll');
|
||||
f.context.webContents.executeJavaScript=async()=>({controls:[{selector:'#search',role:'input',label:'Search',value:'Public query',text:''}]});
|
||||
assert.equal((await f.actions.inspect({})).controls[0].value,'Public query');
|
||||
f.context.webContents.executeJavaScript=async()=>({controls:[{value:'password=hunter22'}]});
|
||||
await assert.rejects(f.actions.inspect({}),/Sensitive/);
|
||||
f.context.webContents.executeJavaScript=async()=>{f.setVersion(2);return{controls:[]};};
|
||||
await assert.rejects(f.actions.inspect({}),/changed/);
|
||||
});
|
||||
test('agent scroll is bounded and restricted to an interactive foreground session',async()=>{
|
||||
const f=fixture();f.context.policy.agents.scopes.push('scroll');
|
||||
assert.equal((await f.actions.scroll({x:0,y:200})).completed,true);
|
||||
await assert.rejects(f.actions.scroll({x:0,y:-1}),/Invalid scroll/);
|
||||
});
|
||||
test('structured DOM inspection excludes credentials and hidden inputs',async()=>{
|
||||
const f=fixture();f.context.policy.agents.scopes.push('inspect');
|
||||
f.context.webContents.executeJavaScript=async code=>{
|
||||
const element=(id,type,value,hidden=false)=>({id,type,value,tagName:'INPUT',parentElement:null,innerText:'',getAttribute:a=>a==='id'?id:null,closest:()=>null,getClientRects:()=>hidden?[]:[{}]});
|
||||
const elements=[element('search','text','Public value'),element('password','password','PRIVATE_PASSWORD_SENTINEL'),element('token','text','PRIVATE_TOKEN_SENTINEL'),element('hidden','text','HIDDEN_SENTINEL',true)];
|
||||
return vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelectorAll:s=>s.startsWith('#')?elements.filter(e=>'#'+e.id===s):elements},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
|
||||
};
|
||||
const result=await f.actions.inspect({});assert.equal(result.controls.length,1);assert.equal(result.controls[0].selector,'#search');assert.equal(result.controls[0].value,'Public value');
|
||||
});
|
||||
|
||||
function formFixture(node) {
|
||||
const f=fixture(); f.context.policy.agents.scopes.push('select','check');
|
||||
let clicks=0;
|
||||
class Select {} Object.defineProperty(Select.prototype,'value',{set(value){this.value=value;}});
|
||||
for(const option of node.options||[]) {option.getAttribute??=()=>null;option.parentElement??=node;}
|
||||
node.getAttribute ??= ()=>null; node.closest ??= ()=>null; node.getClientRects ??= ()=>[{}]; node.dispatchEvent ??= ()=>{};
|
||||
node.click ??= ()=>{clicks++;node.checked=!node.checked;};
|
||||
f.context.webContents.executeJavaScript=async code=>vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelector:()=>node,querySelectorAll:()=>[node]},getComputedStyle:e=>e.style??{display:'block',visibility:'visible',opacity:'1'},HTMLSelectElement:Select,Event:class {constructor(type){this.type=type;}}});
|
||||
return {...f,clicks:()=>clicks};
|
||||
}
|
||||
test('fixed select chooses a unique enabled existing option and checks hostile handlers',async()=>{
|
||||
const node={tagName:'SELECT',value:'a',options:[{value:'a'},{value:'b'}]};
|
||||
const f=formFixture(node);assert.equal((await f.actions.select({selector:'#choice',value:'b'})).completed,true);assert.equal(node.value,'b');
|
||||
node.options.push({value:'b',getAttribute:()=>null,parentElement:node});await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/ambiguous/);node.options.pop();
|
||||
node.options[1].disabled=true;await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].disabled=false;
|
||||
node.options[1].parentElement={disabled:true};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].parentElement=node;
|
||||
node.options[1].getAttribute=a=>a==='data-sensitive'?'':null;await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].getAttribute=()=>null;
|
||||
node.options[1].parentElement={hidden:true,getAttribute:()=>null,parentElement:node};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].parentElement=node;
|
||||
await assert.rejects(f.actions.select({selector:'#choice',value:'missing'}),/unavailable/);
|
||||
node.dispatchEvent=()=>{node.value='a';};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/changed/);
|
||||
});
|
||||
test('checkbox check is idempotent, rejects radio and verifies hostile toggles',async()=>{
|
||||
const node={tagName:'INPUT',type:'checkbox',checked:false};const f=formFixture(node);
|
||||
await f.actions.check({selector:'#choice',checked:true});assert.equal(f.clicks(),1);
|
||||
await f.actions.check({selector:'#choice',checked:true});assert.equal(f.clicks(),1);
|
||||
await f.actions.check({selector:'#choice',checked:false});assert.equal(f.clicks(),2);
|
||||
node.type='radio';await assert.rejects(f.actions.check({selector:'#choice',checked:true}),/cannot be checked/);node.type='checkbox';
|
||||
node.click=()=>{};await assert.rejects(f.actions.check({selector:'#choice',checked:true}),/changed/);
|
||||
await assert.rejects(f.actions.check({selector:'#choice',checked:'true'}),/Invalid checked/);
|
||||
});
|
||||
test('select and check reject hidden, credential and disabled-ancestor targets',async()=>{
|
||||
for(const mode of ['select','check'])for(const kind of ['hidden','password','disabled','ancestor','sensitive-empty','sensitive-body']) {
|
||||
const node={tagName:mode==='select'?'SELECT':'INPUT',type:mode==='check'?'checkbox':'select-one',checked:false,options:[{value:'b'}]};const f=formFixture(node);
|
||||
if(kind==='sensitive-empty')node.getAttribute=a=>a==='data-sensitive'?'':null;
|
||||
if(kind==='sensitive-body')node.parentElement={parentElement:null,getAttribute:a=>a==='data-sensitive'?'':null};
|
||||
if(kind==='hidden')node.getClientRects=()=>[];
|
||||
if(kind==='password')node.getAttribute=a=>a==='name'?'password':null;
|
||||
if(kind==='disabled')node.closest=()=>({});
|
||||
if(kind==='ancestor')node.parentElement={parentElement:null,getAttribute:()=>null,style:{display:'none',visibility:'visible',opacity:'1'}};
|
||||
await assert.rejects(f.actions[mode]({selector:'#choice',value:'b',checked:true}),/unavailable|sensitive/);
|
||||
}
|
||||
});
|
||||
test('select and check retain native generation and scope checks around execution',async()=>{
|
||||
for(const mode of ['select','check']) {
|
||||
const f=fixture();f.context.policy.agents.scopes.push(mode);f.context.webContents.executeJavaScript=async()=>{f.invalidate();return {completed:true};};
|
||||
await assert.rejects(f.actions[mode]({selector:'#choice',value:'b',checked:true}),/changed/);
|
||||
const g=fixture();await assert.rejects(g.actions[mode]({selector:'#choice',value:'b',checked:true}),/scope/);
|
||||
}
|
||||
});
|
||||
|
||||
test('structured inspection returns bounded safe dropdown choices and checkbox state',async()=>{
|
||||
const f=fixture();f.context.policy.agents.scopes.push('inspect');
|
||||
const base={parentElement:null,getAttribute:()=>null,closest:()=>null,getClientRects:()=>[{}]};
|
||||
const select={...base,id:'choice',tagName:'SELECT',value:'one',options:[]};
|
||||
select.options=[{...base,parentElement:select,value:'one',label:'One'},{...base,parentElement:select,value:'locked',label:'Locked',disabled:true},{...base,parentElement:select,value:'secret',label:'SECRET_SENTINEL',getAttribute:a=>a==='data-sensitive'?'':null}];
|
||||
const checkbox={...base,id:'accept',tagName:'INPUT',type:'checkbox',checked:true,value:'on'};const elements=[select,checkbox];
|
||||
f.context.webContents.executeJavaScript=async code=>vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelectorAll:s=>s.startsWith('#')?elements.filter(e=>'#'+e.id===s):elements},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
|
||||
const result=await f.actions.inspect({});assert.equal(result.controls[0].options.length,2);assert.equal(result.controls[0].options[1].disabled,true);assert.equal(result.controls[1].checked,true);assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'),false);
|
||||
});
|
||||
|
||||
test('native history refreshes intentional navigation while binding the original tab',async()=>{
|
||||
for(const action of ['back','forward','reload']) {
|
||||
let f,calls=0;
|
||||
f=fixture({history:async(mode,initial,{signal})=>{
|
||||
calls++;assert.equal(mode,action);assert.equal(initial.sessionId,f.id);assert.equal(signal,undefined);
|
||||
f.setUrl('https://work.example/history');
|
||||
return {completed:true,context:{...f.context,sessionId:f.id,foreground:true,policyVersion:1,token:{generation:1,version:1,url:'https://work.example/history'}}};
|
||||
}});
|
||||
f.context.policy.agents.scopes.push(action);
|
||||
assert.deepEqual(await f.actions[action]({}),{completed:true});assert.equal(calls,1);
|
||||
}
|
||||
});
|
||||
|
||||
test('history actions require explicit scope, native callback and interactive foreground',async()=>{
|
||||
for(const action of ['back','forward','reload']) {
|
||||
const f=fixture();await assert.rejects(f.actions[action]({}),/scope/);
|
||||
f.context.policy.agents.scopes.push(action);await assert.rejects(f.actions[action]({}),/unavailable/);
|
||||
let called=false;
|
||||
const callbacks={getContext:()=>({...f.context,sessionId:f.id,foreground:false,token:{}}),getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},history:async()=>{called=true;},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}};
|
||||
await assert.rejects(createAgentAdapters(callbacks)[action]({}),/unavailable/);assert.equal(called,false);
|
||||
callbacks.getContext=()=>({...f.context,sessionId:f.id,foreground:true,interactive:false,token:{}});
|
||||
await assert.rejects(createAgentAdapters(callbacks)[action]({}),/unavailable/);assert.equal(called,false);
|
||||
}
|
||||
});
|
||||
|
||||
test('history suppresses cancellation, policy races, switched tabs and blocked final destinations',async()=>{
|
||||
for(const kind of ['abort','policy','identity','session','token','blocked','host','scheme','incomplete','scope']) {
|
||||
let f;const controller=new AbortController();
|
||||
f=fixture({history:async()=>{
|
||||
f.setUrl(kind==='scheme'?'file:///private':'https://work.example/history');
|
||||
if(kind==='abort')controller.abort();if(kind==='policy')f.setVersion(2);
|
||||
if(kind==='blocked')f.context.destinationAllowed=()=>false;
|
||||
if(kind==='host')f.context.policy.agents.allowedHosts=['other.example'];
|
||||
if(kind==='scope')f.context.policy.agents.scopes=[];
|
||||
return {completed:kind!=='incomplete',context:{...f.context,sessionId:kind==='session'?'other':f.id,foreground:true,webContents:kind==='identity'?{}:f.context.webContents,policyVersion:kind==='policy'?2:1,token:{generation:kind==='token'?0:1,version:1,url:'https://work.example/history'}}};
|
||||
}});
|
||||
f.context.policy.agents.scopes.push('back');
|
||||
await assert.rejects(f.actions.back({}, {signal:controller.signal}),/changed|unavailable|blocked|scope/);
|
||||
}
|
||||
});
|
||||
|
||||
test('history can leave a start page but reload never invokes native callback there',async()=>{
|
||||
let called=0;const f=fixture({history:async()=>{called++;throw Error('Native target unavailable');}});
|
||||
f.context.policy.agents.scopes.push('back','forward','reload');f.setUrl('about:blank');
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import audit from '../packages/core/agent-audit.cjs';
|
||||
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||
test('agent audit binds every fixed action to its scope and rejects secret-bearing extras',()=>{
|
||||
for(const [action,scope] of Object.entries(audit.AGENT_SCOPES)){
|
||||
const outcome=action==='agent-request'?'denied':'completed';
|
||||
assert.deepEqual(audit.agentAuditFacts({action,outcome}),{action,scope,outcome});
|
||||
assert.throws(()=>audit.AgentAuditSchema.parse({action,scope:'wrong',outcome}));
|
||||
}
|
||||
for(const extra of ['args','prompt','url','principalId','token'])assert.throws(()=>audit.agentAuditFacts({action:'read',outcome:'completed',[extra]:'synthetic-secret'}));
|
||||
assert.throws(()=>audit.agentAuditFacts({action:'agent-request',outcome:'authorized'}));
|
||||
assert.throws(()=>audit.agentAuditFacts({action:'read',outcome:'completed',sessionId:'secret-path'}));
|
||||
});
|
||||
test('agent lifecycle exporters preserve trace and chain fields and exclude invalid metadata',()=>{
|
||||
const sessionId=randomUUID(),metadata={agent:audit.agentAuditFacts({action:'type',outcome:'completed',sessionId}),ruleId:'type:completed',policyVersion:2,traceId:'a'.repeat(32),spanId:'b'.repeat(16)};
|
||||
const event={id:7,time:new Date().toISOString(),kind:'agent.action',device_id:'device',metadata,audit:{hash:'hash',sequence:7}};
|
||||
const ecs=toECS(event),cef=toCEF(event);
|
||||
assert.deepEqual(ecs.safetynet.agent,{action:'type',scope:'browser:interact',outcome:'completed',session_id:sessionId});
|
||||
for(const value of ['cs1Label=RuleId','cs2Label=AgentAction','cs3Label=AgentScope','cs4Label=TraceId','cs5Label=SpanId','cs6Label=AgentOutcome','flexString1Label=AuditRecordHash',`requestContext=${sessionId}`])assert.ok(cef.includes(value),value);
|
||||
event.kind='download.released';assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('AgentAction'),false);
|
||||
event.kind='agent.denied';assert.equal(toECS(event).safetynet.agent,undefined);
|
||||
metadata.agent={...metadata.agent,outcome:'denied',prompt:'synthetic-secret'};assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('synthetic-secret'),false);
|
||||
delete metadata.agent.prompt;assert.equal(toECS(event).safetynet.agent.outcome,'denied');
|
||||
metadata.agent.outcome='failed';assert.equal(toECS(event).event.outcome,'failure');assert.deepEqual(toECS(event).event.type,['error']);
|
||||
metadata.request={bytes:123,detectedType:'text'};assert.ok(toCEF(event).includes('cs6=failed'));assert.equal(toCEF(event).includes('DetectedRequestType'),false);
|
||||
});
|
||||
|
||||
|
||||
test('central agent facts ingestion validates kind pairing and rejects unstructured content',async t=>{
|
||||
const {mkdtempSync,rmSync}=await import('node:fs'),{tmpdir}=await import('node:os'),{join,resolve}=await import('node:path');
|
||||
const {openStore,digest}=await import('../apps/server/store.mjs'),{createApp}=await import('../apps/server/app.mjs');
|
||||
const prefix=join(tmpdir(),'safetynet-agent-audit-'),dir=mkdtempSync(prefix),store=openStore(dir),credential='synthetic-device-agent-audit-token';
|
||||
store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('device','Synthetic',digest(credential));
|
||||
const server=createApp(store,{publicUrl:'http://127.0.0.1',secure:false}).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(resolve(dir).startsWith(resolve(prefix)));rmSync(dir,{recursive:true,force:true});});
|
||||
const send=body=>fetch(`http://127.0.0.1:${server.address().port}/api/device/events`,{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify({eventId:randomUUID(),policyVersion:1,...body})});
|
||||
const agent=audit.agentAuditFacts({action:'read',outcome:'authorized',sessionId:randomUUID()});
|
||||
assert.equal((await send({kind:'agent.action',agent})).status,200);
|
||||
const attributed={...agent,principalId:'agent-'+randomUUID(),credentialGeneration:1};
|
||||
assert.equal((await send({kind:'agent.action',agent:attributed})).status,200);
|
||||
for(const partial of [{...agent,principalId:attributed.principalId},{...agent,credentialGeneration:1}])assert.equal((await send({kind:'agent.action',agent:partial})).status,400);
|
||||
for(const body of [{kind:'agent.denied',agent},{kind:'browser.started',agent},{kind:'agent.action',agent:{...agent,scope:'browser:interact'}},{kind:'agent.action',agent:{...agent,prompt:'synthetic-secret'}}])assert.equal((await send(body)).status,400);
|
||||
assert.equal((await send({kind:'agent.denied',agent:{...agent,outcome:'denied'}})).status,200);
|
||||
assert.equal((await send({kind:'agent.action',ruleId:'read:completed'})).status,200); // Older enrolled client compatibility.
|
||||
const rows=store.db.prepare('SELECT * FROM events').all();assert.equal(rows.length,4);assert.equal(rows.some(row=>row.metadata.includes('synthetic-secret')),false);assert.equal(store.auditIntegrity.verify().valid,true);
|
||||
});
|
||||
|
||||
|
||||
test('agent principal attribution is bounded, paired and never invented for legacy events',()=>{
|
||||
const base={action:'read',outcome:'completed'},principalId='agent:'+randomUUID(),credentialGeneration=2;
|
||||
const parsed=audit.agentAuditFacts({...base,principalId,credentialGeneration});
|
||||
assert.equal(parsed.principalId,principalId);assert.equal(parsed.credentialGeneration,2);
|
||||
for(const extra of [{principalId},{credentialGeneration},{principalId:'a'.repeat(81),credentialGeneration},{principalId:'bad principal',credentialGeneration},{principalId:'https://secret',credentialGeneration},{principalId,credentialGeneration:0},{principalId,credentialGeneration:1.5},{principalId,credentialGeneration:Number.MAX_SAFE_INTEGER+1},{principalId:null,credentialGeneration}])assert.throws(()=>audit.agentAuditFacts({...base,...extra}));
|
||||
assert.equal(audit.agentAuditFacts(base).principalId,undefined);
|
||||
const event={id:9,time:new Date().toISOString(),kind:'agent.action',metadata:{actor:'human-actor',agent:parsed,ruleId:'read:completed',traceId:'a'.repeat(32),spanId:'b'.repeat(16)},audit:{hash:'hash',sequence:9}};
|
||||
const ecs=toECS(event),cef=toCEF(event);
|
||||
assert.equal(ecs.safetynet.agent.principal_id,principalId);assert.equal(ecs.safetynet.agent.credential_generation,2);assert.equal(ecs.user.id,'human-actor');
|
||||
for(const field of [`suid=${principalId}`,'flexNumber1Label=AgentCredentialGeneration','flexNumber1=2','suser=human-actor','cs1Label=RuleId','cs2Label=AgentAction','cs3Label=AgentScope','cs4Label=TraceId','cs5Label=SpanId','cs6Label=AgentOutcome','flexString1Label=AuditRecordHash'])assert.ok(cef.includes(field),field);
|
||||
event.metadata.agent=audit.agentAuditFacts(base);assert.equal(toECS(event).safetynet.agent.principal_id,undefined);assert.equal(toCEF(event).includes('suid='),false);assert.equal(toCEF(event).includes('AgentCredentialGeneration'),false);
|
||||
event.metadata.agent={...parsed,credentialGeneration:undefined};assert.equal(toECS(event).safetynet.agent,undefined);assert.equal(toCEF(event).includes('suid='),false);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{startAgentControl}=require('../apps/browser/agent-control.cjs'),{requestAgent}=require('../apps/browser/agent-transport-client.cjs');
|
||||
test('bookmark and management failures produce static diagnostic reasons and retain fail-closed replies',async t=>{
|
||||
const reports=[];let failure;
|
||||
const api=await startAgentControl({authorize:async()=>({allowed:true,role:'agent',scopes:['bookmarks:mutate']}),actions:{'bookmarks:mutate':async()=>{throw Error(failure);}},audit:async()=>{},onError:async report=>reports.push(report),maxRequestsPerMinute:60});t.after(()=>api.close());
|
||||
const reasons=['Management update in progress. Try again.','Managed policy changed during bookmark update','Bookmark update awaits management synchronization','Bookmark update requires a fresh signed policy','Management request refused','Managed application changed','Invalid bookmark details'];
|
||||
for(const reason of reasons){failure=reason;const response=await requestAgent(api,{action:'bookmarks:mutate',args:{action:'add',expectedRevision:0,policyVersion:1,bookmark:{id:'fixture',title:'Fixture',url:'https://example.com/'}}});assert.equal(response.status,500);const body=await response.json();assert.equal(body.error,'Agent action failed');assert.equal('result' in body,false);assert.equal(reports.at(-1).reason,reason);assert.equal(reports.at(-1).action,'bookmarks:mutate');}
|
||||
failure='Confidential unexpected error https://internal.example/?password=secret';const response=await requestAgent(api,{action:'bookmarks:mutate',args:{action:'delete',id:'fixture',expectedRevision:0,policyVersion:1}});assert.equal(response.status,500);assert.equal(reports.at(-1).reason,'unclassified-action-failure');assert.equal(JSON.stringify(reports).includes('password=secret'),false);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import channel from '../packages/core/policy-channel.cjs';
|
||||
import control from '../apps/browser/agent-control.cjs';
|
||||
import transport from '../apps/browser/agent-transport-client.cjs';
|
||||
const input={action:'bookmarks:mutate',args:{action:'delete',id:'fixture',expectedRevision:7,policyVersion:9}};
|
||||
const busy=()=>Object.assign(Error('Management update in progress. Try again.'),{status:409,code:'browser-management-busy'});
|
||||
async function fixture(t,fn,authorize=async({scope})=>({allowed:true,role:'agent',scopes:[scope]})){
|
||||
let attempts=0,mutations=0,challenges=0;const seen=[];
|
||||
const api=await control.startAgentControl({authorize,audit:async()=>{},traceOperation:async(name,run)=>{if(name==='agent.challenge')challenges++;return run();},actions:{'bookmarks:mutate':async args=>{attempts++;seen.push(args);await fn(attempts);mutations++;return {completed:true};}}});
|
||||
t.after(()=>api.close());return {api,stats:()=>({attempts,mutations,challenges,seen})};
|
||||
}
|
||||
test('authenticated busy retries use fresh challenges and preserve CAS inputs, mutation executes once',async t=>{
|
||||
const x=await fixture(t,n=>{if(n<=2)throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,200);
|
||||
const s=x.stats();assert.equal(s.attempts,3);assert.equal(s.mutations,1);assert.equal(s.challenges,3);for(const args of s.seen)assert.deepEqual(args,input.args);
|
||||
});
|
||||
test('busy retries stop after three retries',async t=>{
|
||||
const x=await fixture(t,()=>{throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,'browser-management-busy');
|
||||
assert.equal(x.stats().attempts,4);assert.equal(x.stats().challenges,4);assert.equal(x.stats().mutations,0);
|
||||
});
|
||||
test('CAS conflicts and denial never retry',async t=>{
|
||||
const x=await fixture(t,()=>{throw Object.assign(Error('Bookmarks changed. Refresh before editing.'),{status:409});});assert.equal((await transport.requestAgent(x.api,input)).status,409);assert.equal(x.stats().attempts,1);
|
||||
const denied=await fixture(t,()=>{},async()=>({allowed:false}));assert.equal((await transport.requestAgent(denied.api,input)).status,403);assert.equal(denied.stats().challenges,1);assert.equal(denied.stats().attempts,0);
|
||||
});
|
||||
test('unauthenticated plaintext busy code cannot trigger retry',async t=>{
|
||||
let calls=0;const server=http.createServer((req,res)=>{calls++;res.writeHead(409,{'content-type':'application/json'});res.end(JSON.stringify({error:'Management update in progress. Try again.',code:'browser-management-busy'}));});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));
|
||||
const response=await transport.requestAgent({address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:'safetynet-policy-mlkem1024-mldsa87-v1'},input);assert.equal(response.status,409);assert.equal(calls,1);assert.equal((await response.json()).code,undefined);
|
||||
});
|
||||
|
||||
test('busy-looking message or code alone does not authorize retry',async t=>{
|
||||
for(const error of [Object.assign(Error('Management update in progress. Try again.'),{status:409}),Object.assign(Error('Unrelated conflict'),{status:409,code:'browser-management-busy'})]){
|
||||
const x=await fixture(t,()=>{throw error;});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,undefined);assert.equal(x.stats().attempts,1);
|
||||
}
|
||||
});
|
||||
|
||||
async function rewriteOuterStatus(api,status,run){
|
||||
const original=globalThis.fetch;
|
||||
globalThis.fetch=async(url,options)=>{const response=await original(url,options);if(String(url)===api.address+'/v1/actions')return new Response(await response.text(),{status,headers:{'content-type':'application/json'}});return response;};
|
||||
try{return await run();}finally{globalThis.fetch=original;}
|
||||
}
|
||||
test('authenticated busy 409 still retries when outer HTTP status is changed to 200',async t=>{
|
||||
const x=await fixture(t,n=>{if(n<=2)throw busy();});
|
||||
const response=await rewriteOuterStatus(x.api,200,()=>transport.requestAgent(x.api,input));assert.equal(response.status,200);assert.equal(x.stats().attempts,3);assert.equal(x.stats().mutations,1);
|
||||
});
|
||||
test('authenticated success 200 never retries or fails when outer HTTP status is changed to 409',async t=>{
|
||||
const x=await fixture(t,()=>{});const response=await rewriteOuterStatus(x.api,409,()=>transport.requestAgent(x.api,input));assert.equal(response.ok,true);assert.equal(response.status,200);assert.equal(x.stats().attempts,1);assert.equal(x.stats().mutations,1);
|
||||
});
|
||||
test('missing or invalid authenticated status refuses the encrypted response',async t=>{
|
||||
const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87');let item,status;
|
||||
const server=http.createServer(async(req,res)=>{
|
||||
const chunks=[];for await(const chunk of req)chunks.push(chunk);res.writeHead(200,{'content-type':'application/json'});
|
||||
if(req.url==='/v1/challenge'){item=channel.challenge('local-agent',ed.privateKey,pq.privateKey);res.end(JSON.stringify(item.bundle));return;}
|
||||
const wire=JSON.parse(Buffer.concat(chunks)),context=channel.receive(wire.handshake,item.bundle,item.privateKey);
|
||||
try{res.end(JSON.stringify(channel.response({kind:'agent-response',...(status===undefined?{}:{status}),result:{completed:true}},item.bundle,context)));}finally{context.responseKey.fill(0);}
|
||||
});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));
|
||||
const credential={address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:channel.VERSION,edPublic:ed.publicKey.export({type:'spki',format:'pem'}),pqPublic:pq.publicKey.export({type:'spki',format:'pem'})};
|
||||
for(const invalid of [undefined,199,600,409.5,'409']){status=invalid;await assert.rejects(transport.requestAgent(credential,input),/Protected agent response refused/);}
|
||||
});
|
||||
@@ -1,14 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import http from 'node:http';
|
||||
const {requestAgent,authorization}=createRequire(import.meta.url)('../apps/browser/agent-transport-client.cjs');
|
||||
const { startAgentControl } = createRequire(import.meta.url)('../apps/browser/agent-control.cjs');
|
||||
const channel=createRequire(import.meta.url)('../packages/core/policy-channel.cjs');
|
||||
async function fixture(t, overrides = {}) {
|
||||
const calls = [], events = [];
|
||||
const api = await startAgentControl({ authorize: async ({scope}) => ({allowed:true, role:'agent',scopes:[scope]}), actions: {sessions:async()=>[{id:'work',title:'Work',cookies:'secret'}], navigate:async args=>{calls.push(args);return{};}, read:async()=>({text:'sanitized',password:'secret'}),type:async()=>({}),click:async()=>({}),ai:async()=>({text:'answer'})},audit:async event=>events.push(event),...overrides });
|
||||
t.after(()=>api.close());
|
||||
const request = (action,args={},headers={})=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${api.token}`,...headers},body:JSON.stringify({action,args})});
|
||||
const request = (action,args={},headers={})=>requestAgent(api,{action,args},{headers});
|
||||
return {api, request,calls,events};
|
||||
}
|
||||
test('postquantum channel refuses plaintext, tampering and replays before adapter execution',async t=>{
|
||||
const {api,calls}=await fixture(t);
|
||||
const headers=(route,body='{}')=>({'Content-Type':'application/json',Authorization:authorization(api.token,route,body)});
|
||||
const send=body=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:headers('/v1/actions',JSON.stringify(body)),body:JSON.stringify(body)});
|
||||
assert.equal((await send({action:'navigate',args:{url:'https://example.com'}})).status,400);
|
||||
const bundle=await(await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}'})).json();
|
||||
channel.verifyChallenge(bundle,'local-agent',api.edPublic,api.pqPublic);
|
||||
const context=channel.prepare(bundle);
|
||||
try{
|
||||
const payload=channel.response({kind:'agent-request',action:'navigate',args:{url:'https://example.com'}},bundle,context);
|
||||
const envelope={handshake:context.request,payload};
|
||||
const wire=JSON.stringify(envelope);
|
||||
assert.equal(wire.includes('https://example.com'),false);assert.equal(wire.includes('navigate'),false);
|
||||
const result=await send(envelope);assert.equal(result.status,200);
|
||||
const response=await result.json();assert.ok(response.encrypted);
|
||||
assert.deepEqual(channel.finish(response,bundle,context),{kind:'agent-response',status:200,result:{completed:true}});
|
||||
assert.equal((await send(envelope)).status,400);assert.equal(calls.length,1);
|
||||
const bundle2=await(await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}'})).json();
|
||||
const ctx2=channel.prepare(bundle2);
|
||||
try{const changed=channel.response({kind:'agent-request',action:'navigate',args:{url:'https://example.com'}},bundle2,ctx2);changed.encrypted.tag='AAAAAAAAAAAAAAAAAAAAAA';assert.equal((await send({handshake:ctx2.request,payload:changed})).status,400);assert.equal(calls.length,1);}finally{ctx2.responseKey.fill(0);}
|
||||
}finally{context.responseKey.fill(0);}
|
||||
});
|
||||
test('postquantum server identity must match protected pairing pins',async t=>{
|
||||
const {api}=await fixture(t);
|
||||
await assert.rejects(requestAgent({...api,pqPublic:api.edPublic},{action:'sessions'}));
|
||||
});
|
||||
test('proof of pairing possession is route-bound, non-secret and one-use',async t=>{
|
||||
const {api}=await fixture(t);const proof=authorization(api.token,'/v1/challenge');
|
||||
assert.equal(proof.includes(api.token),false);
|
||||
const send=route=>fetch(`${api.address}${route}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:proof},body:'{}'});
|
||||
assert.equal((await send('/v1/actions')).status,401);
|
||||
assert.equal((await send('/v1/challenge')).status,200);
|
||||
assert.equal((await send('/v1/challenge')).status,401);
|
||||
const bound=authorization(api.token,'/v1/challenge','{}');
|
||||
assert.equal((await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:bound},body:'{"modified":true}'})).status,401);
|
||||
});
|
||||
test('bounded transport supports maximum multibyte action text',async t=>{
|
||||
let text;const {request}=await fixture(t,{actions:{type:async args=>{text=args.text;return{};}}});
|
||||
const value='界'.repeat(16384);assert.equal((await request('type',{selector:'#message',text:value})).status,200);assert.equal(text,value);
|
||||
});
|
||||
test('encrypted response bounds support maximum multibyte sanitized page text',async t=>{
|
||||
const text='界'.repeat(65536);const {request}=await fixture(t,{actions:{read:async()=>({text})}});
|
||||
const response=await request('read');assert.equal(response.status,200);assert.equal((await response.json()).result.text,text);
|
||||
});
|
||||
test('revocation during completion audit never releases successful content',async t=>{
|
||||
let capability;const {api,request}=await fixture(t,{audit:async event=>{if(event.outcome==='completed')capability.revoke();}});capability=api;
|
||||
const response=await request('read');assert.equal(response.status,403);assert.equal(Object.hasOwn(await response.json(),'result'),false);
|
||||
});
|
||||
test('future-dated possession proofs remain replay-protected for their entire acceptance window',async t=>{
|
||||
const {api}=await fixture(t);const original=Date.now,now=original();let proof;
|
||||
try{Date.now=()=>now+20000;proof=authorization(api.token,'/v1/challenge');Date.now=()=>now;
|
||||
const send=()=>fetch(`${api.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:proof},body:'{}'});
|
||||
assert.equal((await send()).status,200);Date.now=()=>now+35000;assert.equal((await send()).status,401);
|
||||
}finally{Date.now=original;}
|
||||
});
|
||||
test('client rejects plaintext success and tampered encrypted responses',async t=>{
|
||||
const {api}=await fixture(t);let mode='plaintext';
|
||||
const proxy=http.createServer(async(req,res)=>{
|
||||
const chunks=[];for await(const chunk of req)chunks.push(chunk);
|
||||
const upstream=await fetch(`${api.address}${req.url}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(api.token,req.url,Buffer.concat(chunks))},body:Buffer.concat(chunks)});
|
||||
const value=await upstream.json();
|
||||
if(req.url==='/v1/actions'){if(mode==='plaintext'){res.end(JSON.stringify({result:[{id:'forged'}]}));return;}value.encrypted.tag='AAAAAAAAAAAAAAAAAAAAAA';}
|
||||
res.end(JSON.stringify(value));
|
||||
});
|
||||
await new Promise(resolve=>proxy.listen(0,'127.0.0.1',resolve));
|
||||
t.after(()=>new Promise(resolve=>{proxy.closeAllConnections();proxy.close(resolve);}));
|
||||
const credential={...api,address:`http://127.0.0.1:${proxy.address().port}`};
|
||||
await assert.rejects(requestAgent(credential,{action:'sessions'}),/Protected agent response required/);
|
||||
mode='tamper';await assert.rejects(requestAgent(credential,{action:'sessions'}));
|
||||
});
|
||||
test('loopback capability authenticates and strips private adapter fields',async t=>{
|
||||
const {request,calls,events}=await fixture(t);
|
||||
assert.equal((await request('sessions',{}, {Authorization:'Bearer wrong'})).status,401);
|
||||
@@ -18,6 +91,15 @@ test('loopback capability authenticates and strips private adapter fields',async
|
||||
assert.equal(calls.length,1); assert.ok(events.some(e=>e.outcome==='authorized'));
|
||||
});
|
||||
|
||||
test('library organization actions are typed and never project credentials or executable instructions',async t=>{
|
||||
const {request}=await fixture(t,{actions:{'library:organize-plan':async()=>({proposalId:'proposal-1',plan:{bookmarks:[{id:'b',folder:'Work',url:'https://secret.test'}],tabs:[{id:'t',group:'Research',script:'evil'}]},binding:{credential:'private'},expiresAt:{token:'private'}}),'library:organize-apply':async()=>({completed:true,token:'private'})}});
|
||||
assert.equal((await request('library:organize-plan',{prompt:'execute JS'})).status,400);
|
||||
const result=(await(await request('library:organize-plan')).json()).result;
|
||||
assert.deepEqual(result,{proposalId:'proposal-1',plan:{bookmarks:[{id:'b',folder:'Work'}],tabs:[{id:'t',group:'Research'}]},expiresAt:null});
|
||||
assert.equal((await request('library:organize-apply',{proposalId:'proposal-1',confirm:'yes'})).status,400);
|
||||
assert.deepEqual((await(await request('library:organize-apply',{proposalId:'proposal-1',confirm:true})).json()).result,{completed:true});
|
||||
});
|
||||
|
||||
test('omitted session remains omitted and native inventory exposes only approved foreground metadata',async t=>{
|
||||
let captured;
|
||||
const {request}=await fixture(t,{actions:{sessions:async()=>[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true,cookies:'secret'}],read:async args=>{captured=args;return{text:'safe'};}}});
|
||||
@@ -76,7 +158,7 @@ test('revocation immediately disables capability and policy denial changes live'
|
||||
});
|
||||
test('timeouts abort adapter and rate limit prevents repeated actions',async t=>{
|
||||
let signal;
|
||||
const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:1,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}});
|
||||
const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:2,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}});
|
||||
assert.equal((await request('read',{sessionId:'work'})).status,504);assert.equal(signal.aborted,true);
|
||||
assert.equal((await request('read',{sessionId:'work'})).status,429);
|
||||
});
|
||||
@@ -94,3 +176,6 @@ test('trusted diagnostics report known static reasons and suppress arbitrary sen
|
||||
assert.equal(diagnostics[1].reason,'unclassified-action-failure');
|
||||
assert.equal(JSON.stringify(diagnostics).includes('do-not-log-this'),false);
|
||||
});
|
||||
|
||||
test('fixed form actions enforce typed scopes and never accept caller code',async t=>{const calls=[];const {request}=await fixture(t,{actions:{select:async args=>{calls.push(args);return {completed:true};},check:async args=>{calls.push(args);return {completed:true};}}});assert.equal((await request('select',{sessionId:'work',selector:'#language',value:''})).status,200);assert.equal((await request('check',{sessionId:'work',selector:'#remember',checked:false})).status,200);for(const checked of ['true',1,null])assert.equal((await request('check',{selector:'#remember',checked})).status,400);assert.equal((await request('select',{selector:'#language',value:'one',script:'alert(1)'})).status,400);assert.equal(calls.length,2);});
|
||||
test('inspection projects only bounded option and checkbox metadata',async t=>{const {request}=await fixture(t,{actions:{inspect:async()=>({text:'',controls:[{selector:'#language',role:'select',options:[{value:'en',label:'English',disabled:false,secret:'must-not-pass'}]},{selector:'#remember',role:'input',checked:true,cookies:'must-not-pass'}]})}});const r=await request('inspect');assert.equal(r.status,200);const data=await r.json();assert.equal(data.result.controls[0].options[0].value,'en');assert.equal(data.result.controls[1].checked,true);assert.equal(JSON.stringify(data).includes('must-not-pass'),false);});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {AgentsSchema,AgentGrantSchema} from '../packages/core/security.mjs';
|
||||
import {defaultPolicy,PolicySchema} from '../packages/core/policy.mjs';
|
||||
import {applyPolicyGroups} from '../packages/core/policy-groups.mjs';
|
||||
import {applyUserProfiles} from '../packages/core/user-profiles.mjs';
|
||||
const grant=()=>({id:'research-agent',name:'Research agent',enabled:true,expiresAt:'2099-12-31T23:59:59Z',scopes:['sessions','read'],allowedHosts:['*.company.example'],generation:1});
|
||||
test('individual agent grants are explicit bounded unique and secret free',()=>{assert.equal(AgentsSchema.parse({}).identityMode,'legacy');assert.deepEqual(AgentsSchema.parse({}).grants,[]);const a=AgentsSchema.parse({identityMode:'individual',grants:[grant()]});assert.equal(a.identityMode,'individual');assert.deepEqual(a.grants[0],grant());for(const change of [{id:'bad id'},{generation:0},{generation:1.5},{expiresAt:'invalid'},{scopes:['not-a-scope']},{scopes:['read','read']},{privateKey:'secret'},{token:'secret'},{name:''}])assert.equal(AgentGrantSchema.safeParse({...grant(),...change}).success,false);assert.equal(AgentsSchema.safeParse({grants:[grant(),grant()]}).success,false);assert.equal(AgentsSchema.safeParse({grants:Array.from({length:33},(_,i)=>({...grant(),id:`a${i}`}))}).success,false);assert.deepEqual(AgentGrantSchema.parse({...grant(),scopes:[]}).scopes,[]);});
|
||||
test('signed policy schema preserves individual grants through restrictive group and profile assignment',()=>{const p=defaultPolicy();p.agents=AgentsSchema.parse({enabled:true,identityMode:'individual',grants:[grant()]});const parsed=PolicySchema.parse(p);assert.deepEqual(parsed.agents.grants,[grant()]);const grouped=applyPolicyGroups(parsed,[{id:'locked',name:'Locked',memberDeviceIds:['one'],restrictions:{disableAgents:true},rollout:{stage:'all',deviceIds:[]}}],'one');assert.equal(grouped.agents.enabled,false);assert.equal(grouped.agents.identityMode,'individual');assert.deepEqual(grouped.agents.grants,[grant()]);const profiled=applyUserProfiles(parsed,{profiles:[{id:'restricted',name:'Restricted',deviceIds:['one'],features:{agents:'deny'}}]}, {id:'one'});assert.equal(profiled.agents.enabled,false);assert.deepEqual(profiled.agents.grants,[grant()]);assert.equal(p.agents.enabled,true);});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {verifyPolicy} from '../packages/core/policy.mjs';
|
||||
const {reconcileGenerationFloor}=createRequire(import.meta.url)('../apps/browser/agent-grants.cjs');
|
||||
async function fixture(t){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-agent-publication-')),store=openStore(dir),origin='http://127.0.0.1:4310';
|
||||
const server=createApp(store,{publicUrl:origin,secure:false,requireIndependentPolicyApproval:true}).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});});
|
||||
async function api(path,method='GET',body,subject='author',role='admin'){
|
||||
const credential=`synthetic-agent-publication-${subject}-12345678901234567890`;
|
||||
store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:subject,role}));
|
||||
const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${credential}`,origin,'content-type':'application/json'},body:body===undefined?undefined:JSON.stringify(body)});
|
||||
return {status:response.status,body:await response.json()};
|
||||
}
|
||||
const approve=(review,subject='reviewer',role='admin')=>api(`/api/admin/policy-reviews/${review.reviewId}/approve`,'POST',{payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true},subject,role);
|
||||
return {store,api,approve,signed:()=>verifyPolicy(store.envelope(),store.publicKey())};
|
||||
}
|
||||
const grant=generation=>({id:'research-agent',name:'Research Agent',enabled:true,expiresAt:'2099-12-31T23:59:59Z',scopes:['sessions','read'],allowedHosts:['*.company.example'],generation});
|
||||
test('individual grants require independent review and survive signed history restoration without credential resurrection',async t=>{
|
||||
const f=await fixture(t),candidate=f.store.get('policy');candidate.version++;candidate.agents={...candidate.agents,enabled:true,identityMode:'individual',scopes:['sessions','read'],allowedHosts:['*.company.example'],grants:[grant(1)]};
|
||||
const queued=await f.api('/api/admin/policy','PUT',candidate);assert.equal(queued.status,202);assert.equal(f.signed().agents.enabled,false);
|
||||
const detail=await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}`);assert.equal(detail.status,200);assert.ok(detail.body.diff.changes.some(c=>c.path==='/agents/grants'&&c.after[0].id==='research-agent'));
|
||||
assert.equal((await f.approve(queued.body,'author')).status,403);assert.equal((await f.approve(queued.body,'auditor','auditor')).status,403);assert.equal((await f.approve(queued.body)).status,200);assert.deepEqual(f.signed().agents.grants,[grant(1)]);
|
||||
const floor1=reconcileGenerationFloor(f.signed().agents.grants).floor;
|
||||
const rotated=f.store.get('policy');rotated.version++;rotated.agents.grants[0].generation=2;rotated.agents.grants[0].scopes=['sessions'];const rotation=await f.api('/api/admin/policy','PUT',rotated);assert.equal(rotation.status,202);assert.equal((await f.approve(rotation.body)).status,200);
|
||||
const floor2=reconcileGenerationFloor(f.signed().agents.grants,floor1).floor;assert.equal(floor2['research-agent'],2);
|
||||
const restore=await f.api('/api/admin/policy/restore','POST',{sourceVersion:2,expectedVersion:3,approve:true});assert.equal(restore.status,202);assert.equal(f.signed().version,3);
|
||||
const restoreReview=await f.api(`/api/admin/policy-reviews/${restore.body.reviewId}`);assert.ok(restoreReview.body.diff.changes.some(c=>c.path==='/agents/grants'&&c.after[0].generation===1));assert.equal((await f.approve(restore.body,'author')).status,403);assert.equal((await f.approve(restore.body)).status,200);
|
||||
const restored=f.signed();assert.equal(restored.version,4);assert.deepEqual(restored.agents.grants,[grant(1)]);const effective=reconcileGenerationFloor(restored.agents.grants,floor2);assert.equal(effective.grants[0].enabled,false);assert.equal(effective.floor['research-agent'],2);
|
||||
const history=await f.api('/api/admin/policy/history/2');assert.deepEqual(history.body.policy.agents.grants,[grant(1)]);
|
||||
assert.equal((await f.api('/api/admin/policy','PUT',{...restored,version:5},'auditor','auditor')).status,403);assert.equal((await f.api('/api/admin/policy/restore','POST',{sourceVersion:2,expectedVersion:4,approve:true},'auditor','auditor')).status,403);assert.equal(f.signed().version,4);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import grants from '../apps/browser/agent-grants.cjs';
|
||||
const now=Date.now(),expiry=new Date(now+60000).toISOString();
|
||||
const policy=()=>({expiresAt:expiry,agents:{enabled:true,identityMode:'individual',scopes:['read','navigate'],allowedHosts:['corp.example'],grants:[{id:'worker.a',name:'Reader',enabled:true,expiresAt:expiry,scopes:['read'],allowedHosts:['corp.example'],generation:2}]}});
|
||||
const authorize=changes=>grants.authorizeGrant({policy:policy(),ready:true,principalId:'worker.a',generation:2,action:'read',url:'https://corp.example/',hostMatches:(a,b)=>a===b,now,...changes});
|
||||
test('active grant discovery excludes expired and disabled grants and exposes no mutable policy reference',()=>{
|
||||
const p=policy();p.agents.grants.push({...p.agents.grants[0],id:'disabled',enabled:false},{...p.agents.grants[0],id:'expired',expiresAt:new Date(now-1).toISOString()});
|
||||
const active=grants.activeGrants(p,now);assert.deepEqual(active.map(g=>g.id),['worker.a']);active[0].scopes.push('navigate');assert.deepEqual(p.agents.grants[0].scopes,['read']);
|
||||
assert.deepEqual(grants.activeGrants(p,now+60001),[]);
|
||||
});
|
||||
test('individual authorization intersects global and principal rights and destinations',()=>{
|
||||
assert.equal(authorize().allowed,true);
|
||||
for(const changes of [{principalId:'worker.b'},{generation:1},{action:'navigate'},{ready:false},{url:'https://evil.example/'},{url:'file:///secret'},{now:now+60001}])assert.equal(authorize(changes).allowed,false);
|
||||
const p=policy();p.agents.scopes=[];assert.equal(authorize({policy:p}).allowed,false);
|
||||
p.agents.scopes=['read'];p.agents.grants[0].scopes=[];assert.equal(authorize({policy:p}).allowed,false);
|
||||
p.agents.grants[0].scopes=['read'];p.agents.grants[0].allowedHosts=['other.example'];assert.equal(authorize({policy:p}).allowed,false);
|
||||
});
|
||||
test('legacy mode never accepts individual credentials and individual mode has no shared bypass',()=>{
|
||||
assert.equal(authorize({principalId:undefined,generation:undefined}).allowed,false);
|
||||
const p=policy();p.agents.identityMode='legacy';assert.equal(authorize({policy:p}).allowed,false);
|
||||
assert.equal(authorize({policy:p,principalId:undefined,generation:undefined}).allowed,true);
|
||||
delete p.agents.identityMode;assert.equal(authorize({policy:p,principalId:undefined,generation:undefined}).allowed,false);
|
||||
});
|
||||
test('generation floor survives removal and restart and disables restored older credentials',()=>{
|
||||
const first=grants.reconcileGenerationFloor(policy().agents.grants,{});
|
||||
const removed=grants.reconcileGenerationFloor([],JSON.parse(JSON.stringify(first.floor)));
|
||||
const restored=grants.reconcileGenerationFloor([{...policy().agents.grants[0],generation:1}],removed.floor);
|
||||
assert.equal(restored.grants[0].enabled,false);assert.equal(restored.floor['worker.a'],2);
|
||||
assert.equal(grants.reconcileGenerationFloor([{...policy().agents.grants[0],generation:3}],restored.floor).grants[0].enabled,true);
|
||||
});
|
||||
test('protected credential publication is atomic, bounded to hashed names and stale-safe',async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'agent-grants-'));
|
||||
try{
|
||||
const filePath=grants.descriptorPath(dir,'worker.a');assert.match(path.basename(filePath),/^agent-[a-f0-9]{64}\.json$/);
|
||||
await grants.persistConnection({filePath,connection:{token:'synthetic-secret'},protect:async()=> 'opaque-dpapi',isCurrent:()=>true});
|
||||
const prior=fs.readFileSync(filePath,'utf8');assert.equal(prior.includes('synthetic-secret'),false);
|
||||
let current=true;
|
||||
await assert.rejects(grants.persistConnection({filePath,connection:{token:'other'},protect:async()=>{current=false;return 'new';},isCurrent:()=>current}),/context changed/);
|
||||
assert.equal(fs.readFileSync(filePath,'utf8'),prior);assert.equal(fs.readdirSync(dir).length,1);
|
||||
const linked=path.join(dir,'linked.json');fs.linkSync(filePath,linked);
|
||||
await assert.rejects(grants.persistConnection({filePath,connection:{},protect:async()=>'',isCurrent:()=>true}),/Unsafe credential/);
|
||||
}finally{fs.rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{startAgentControl}=require('../apps/browser/agent-control.cjs'),{requestAgent,authorization}=require('../apps/browser/agent-transport-client.cjs'),channel=require('../packages/core/policy-channel.cjs');
|
||||
const grant=(id,more={})=>({id,generation:1,enabled:true,expiresAt:new Date(Date.now()+60000).toISOString(),scopes:['sessions:list'],...more});
|
||||
async function fixture(t,extra={}){const grants=[grant('agent.one'),grant('agent-two')],events=[];const api=await startAgentControl({principalMode:'individual',principals:grants,authorize:async({scope,principalId,generation})=>({allowed:!!principalId&&generation===1,role:'agent',scopes:[scope]}),actions:{sessions:async()=>[{id:'work'}]},audit:async e=>events.push(e),...extra});t.after(()=>api.close());return {api,grants,events,one:api.issuePrincipal(...[grants[0].id,1,grants[0].expiresAt]),two:api.issuePrincipal(...[grants[1].id,1,grants[1].expiresAt])};}
|
||||
const send=(c,route,body='{}')=>fetch(`${c.address}${route}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(c.token,route,body,c)},body});
|
||||
test('individual principals have separate capabilities, authenticated identity and no shared master',async t=>{let persisted=false;const f=await fixture(t,{persistCredential:()=>{persisted=true;}});assert.equal(f.api.token,undefined);assert.equal(persisted,false);assert.notEqual(f.one.token,f.two.token);for(const c of [f.one,f.two]){const response=await requestAgent(c,{action:'sessions'});assert.equal(response.ok,true);}assert.ok(f.events.some(e=>e.principalId==='agent.one'&&e.generation===1&&e.outcome==='completed'));assert.ok(f.events.some(e=>e.principalId==='agent-two'&&e.outcome==='completed'));const r=await fetch(`${f.one.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(f.one.token,'/v1/challenge')},body:'{}'});assert.equal(r.status,401);});
|
||||
test('another principal cannot consume a signed challenge and client checks principal binding',async t=>{const f=await fixture(t);const bundle=await(await send(f.one,'/v1/challenge')).json();channel.verifyChallenge(bundle,f.one.principalBinding,f.one.edPublic,f.one.pqPublic);assert.throws(()=>channel.verifyChallenge(bundle,f.two.principalBinding,f.two.edPublic,f.two.pqPublic));const ctx=channel.prepare(bundle);try{const body=JSON.stringify({handshake:ctx.request,payload:channel.response({kind:'agent-request',action:'sessions',args:{}},bundle,ctx)});assert.equal((await send(f.two,'/v1/actions',body)).status,400);const r=await send(f.one,'/v1/actions',body);assert.equal(r.status,200);assert.equal(channel.finish(await r.json(),bundle,ctx).status,200);}finally{ctx.responseKey.fill(0);}});
|
||||
test('individual revocation leaves other principal usable and old generation cannot return',async t=>{const f=await fixture(t);f.api.reconcilePrincipals([{...f.grants[0],enabled:false},f.grants[1]]);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);const rotated={...f.grants[0],generation:2};f.api.reconcilePrincipals([rotated,f.grants[1]]);const newer=f.api.issuePrincipal(rotated.id,2,rotated.expiresAt);assert.notEqual(newer.token,f.one.token);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.equal((await send(newer,'/v1/challenge')).status,200);});
|
||||
test('rights changes abort only owned operation and retain same-generation capability',async t=>{let started,release;const began=new Promise(r=>started=r),wait=new Promise(r=>release=r);const f=await fixture(t,{actions:{sessions:async(_args,{signal})=>{started();await wait;assert.equal(signal.aborted,true);return[{id:'work'}];}}});const pending=requestAgent(f.one,{action:'sessions'});await began;f.api.reconcilePrincipals([{...f.grants[0],scopes:[]},f.grants[1]]);release();assert.equal((await pending).status,403);assert.equal(f.api.issuePrincipal(f.one.principalId,1,f.grants[0].expiresAt).token,f.one.token);assert.equal((await send(f.two,'/v1/challenge')).status,200);});
|
||||
test('principal expiry aborts in-flight result and removes owned capability',async t=>{let started,release;const began=new Promise(r=>started=r),wait=new Promise(r=>release=r);const f=await fixture(t,{actions:{sessions:async()=>{started();await wait;return[{id:'work'}];}}});const short={...f.grants[0],expiresAt:new Date(Date.now()+250).toISOString()};f.api.reconcilePrincipals([short,f.grants[1]]);const credential=f.api.issuePrincipal(short.id,1,short.expiresAt);const pending=requestAgent(credential,{action:'sessions'});await began;await new Promise(r=>setTimeout(r,350));release();assert.equal((await pending).status,403);assert.equal((await send(credential,'/v1/challenge')).status,401);});
|
||||
import {AsyncLocalStorage} from 'node:async_hooks';
|
||||
test('each protected RPC has one parent trace and timed named child stages without content',async t=>{const storage=new AsyncLocalStorage(),spans=[];let seq=0;const traceOperation=async(name,fn)=>{const parent=storage.getStore(),row={id:++seq,name,parent:parent?.id,start:performance.now()};spans.push(row);return storage.run(row,async()=>{try{return await fn();}finally{row.duration=performance.now()-row.start;}});};const f=await fixture(t,{traceOperation});assert.equal((await requestAgent(f.one,{action:'sessions'})).ok,true);const parents=spans.filter(s=>s.name==='agent.request');assert.equal(parents.length,2);assert.ok(parents.every(s=>s.parent===undefined&&s.duration>=0));const actionParent=parents.find(p=>spans.some(s=>s.parent===p.id&&s.name==='agent.dispatch'));for(const name of ['agent.decrypt','agent.authorize','agent.audit','agent.dispatch','agent.project','agent.encrypt'])assert.ok(spans.some(s=>s.parent===actionParent.id&&s.name===name&&s.duration>=0));assert.equal(JSON.stringify(spans).includes(f.one.token),false);});
|
||||
test('individual release boundary reauthorization prevents a post-dispatch denial from releasing content',async t=>{let allowed=true;const f=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]}),actions:{sessions:async()=>{allowed=false;return[{id:'private-result'}];}}});const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('private-result'),false);});
|
||||
test('generation rollback stays revoked even when old grant is reenabled',async t=>{const f=await fixture(t);f.api.reconcilePrincipals([{...f.grants[0],generation:2},f.grants[1]]);f.api.reconcilePrincipals(f.grants);assert.equal((await send(f.one,'/v1/challenge')).status,401);assert.throws(()=>f.api.issuePrincipal(f.one.principalId,1,f.grants[0].expiresAt));assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);});
|
||||
|
||||
test('forged individual authentication never attributes an unverified actor',async t=>{const f=await fixture(t);const forged={...f.one,token:f.two.token};const r=await send(forged,'/v1/challenge');assert.equal(r.status,401);assert.equal(f.events.length,1);assert.equal(f.events[0].principalId,undefined);assert.equal(f.events[0].generation,undefined);assert.equal(f.events[0].outcome,'denied');});
|
||||
test('dispatch receives only the authenticated principal and generation',async t=>{let received;const f=await fixture(t,{actions:{sessions:async(_args,context)=>{received=context;return[{id:'work'}];}}});assert.equal((await requestAgent(f.two,{action:'sessions'})).ok,true);assert.equal(received.principalId,f.two.principalId);assert.equal(received.generation,f.two.generation);assert.ok(received.signal instanceof AbortSignal);});
|
||||
|
||||
test('revocation during asynchronous encryption never releases computed content',async t=>{let api;const f=await fixture(t,{traceOperation:async(name,fn)=>{const result=await fn();if(name==='agent.encrypt')api.reconcilePrincipals([]);return result;}});api=f.api;const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('work'),false);});
|
||||
|
||||
test('readiness denial during encryption is rechecked before encrypted result release',async t=>{let allowed=true;const f=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]}),traceOperation:async(name,fn)=>{const result=await fn();if(name==='agent.encrypt')allowed=false;return result;}});const response=await requestAgent(f.one,{action:'sessions'});assert.equal(response.status,403);assert.equal(JSON.stringify(await response.json()).includes('work'),false);});
|
||||
@@ -0,0 +1,20 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const source=fs.readFileSync(new URL('../apps/browser/renderer.js',import.meta.url),'utf8');
|
||||
function fixture(state){
|
||||
const elements=new Map(),calls=[];let tick;
|
||||
class Element{constructor(tag){this.tag=tag;this.children=[];this.open=false;this.listeners={};this.textContent='';}set id(value){this._id=value;elements.set(value,this);}get id(){return this._id;}setAttribute(){}append(...nodes){this.children.push(...nodes);}replaceChildren(){this.children=[];}addEventListener(name,fn){this.listeners[name]=fn;}showModal(){this.open=true;}close(){this.open=false;this.listeners.close?.();}}
|
||||
const agents=new Element('button');agents.id='agents';
|
||||
const context={lastState:state,Date,JSON,Number,Array,Error,setInterval:fn=>{tick=fn;},document:{createElement:tag=>new Element(tag),body:new Element('body')},$:id=>elements.get(id),requireSection:()=>{if(state.blocked)throw Error('managed');},call:async(method,id)=>{calls.push({method,id});return {address:'http://127.0.0.1:4567',credentialPath:'protected.json'};},updateChrome:async()=>{},task:fn=>fn,button:(label,fn)=>{const e=new Element('button');e.textContent=label;e.onclick=fn;return e;}};
|
||||
vm.createContext(context);vm.runInContext(source.slice(source.indexOf('// Pairing selects'),source.indexOf("$('ai-form').onsubmit")),context);
|
||||
return {context,elements,calls,tick:()=>tick()};
|
||||
}
|
||||
const grant={id:'reviewer',name:'Security reviewer',generation:2,expiresAt:new Date(Date.now()+60000).toISOString()};
|
||||
test('individual pairing requires selecting a published identity and closes on revocation',async()=>{
|
||||
const f=fixture({ready:true,agentsEnabled:true,agentIdentityMode:'individual',agentGrants:[grant]});await f.context.openAgentPairing();assert.equal(f.calls.length,0);const list=f.elements.get('agent-grant-list');assert.equal(list.children.length,1);await list.children[0].onclick();assert.equal(f.calls[0].id,'reviewer');assert.match(f.elements.get('agent-pairing-result').textContent,/Security reviewer/);f.context.lastState.agentGrants=[];f.tick();assert.equal(f.elements.get('agent-pairing').open,false);assert.equal(f.elements.get('agent-pairing-result').textContent,'');
|
||||
});
|
||||
test('empty individual mode cannot issue legacy credential and managed section rejects pairing',async()=>{
|
||||
const state={ready:true,agentsEnabled:true,agentIdentityMode:'individual',agentGrants:[]},f=fixture(state);await f.context.openAgentPairing();assert.match(f.elements.get('agent-grant-list').children[0].textContent,/No active agent/);assert.equal(f.calls.length,0);state.blocked=true;await assert.rejects(f.context.openAgentPairing(),/managed/);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync,readFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {startSIEM} from '../apps/server/siem.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
import {adminRoutePermission} from '../apps/server/admin-access.mjs';
|
||||
test('audit API verifies real transactional events and exposes bounded metadata-only proof',{timeout:20000},async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-audit-api-')),store=openStore(directory),credential='test-only-audit-administrator-123456789';
|
||||
const sdk=startTelemetry();const server=createApp(store,{publicUrl:'http://127.0.0.1',adminToken:credential,secure:false}).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));await sdk?.shutdown();store.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-audit-api-')));rmSync(directory,{recursive:true,force:true});});
|
||||
const url=`http://127.0.0.1:${server.address().port}`,headers={authorization:`Bearer ${credential}`};
|
||||
store.event(null,'dlp.block',{ruleId:'test',content:'SYNTHETIC_CONTENT_NOT_IN_PROOF'});
|
||||
assert.equal((await fetch(url+'/api/admin/audit/integrity')).status,401);
|
||||
assert.equal(adminRoutePermission('GET','/api/admin/audit/integrity'),'audit.read');
|
||||
assert.equal(adminRoutePermission('GET','/api/admin/audit/entries'),'audit.read');
|
||||
let response=await fetch(url+'/api/admin/audit/integrity',{headers});assert.equal(response.status,200);let result=await response.json();assert.equal(result.valid,true);assert.equal(result.authenticated,false);
|
||||
response=await fetch(url+'/api/admin/audit/entries?limit=1',{headers});assert.equal(response.status,200);assert.equal((await response.text()).includes('SYNTHETIC_CONTENT_NOT_IN_PROOF'),false);
|
||||
assert.equal((await fetch(url+'/api/admin/audit/entries?limit=1001',{headers})).status,400);
|
||||
const exporter=startSIEM(store,directory,{});try{await exporter.flush();const event=JSON.parse(readFileSync(join(directory,'security.ndjson'),'utf8').trim());assert.equal(event.safetynet.audit.hash,store.auditIntegrity.linkage(1).hash);assert.equal(JSON.stringify(event).includes('SYNTHETIC_CONTENT_NOT_IN_PROOF'),false);}finally{exporter.stop();}
|
||||
store.db.prepare("UPDATE events SET kind='changed' WHERE id=1").run();response=await fetch(url+'/api/admin/audit/integrity',{headers});result=await response.json();assert.equal(result.valid,false);assert.ok(result.errors.includes('event-digest'));
|
||||
});
|
||||
test('failed audit append rolls back event and Sigma alert atomically',t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-audit-atomic-')),s=openStore(directory);t.after(()=>{s.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-audit-atomic-')));rmSync(directory,{recursive:true,force:true});});
|
||||
s.db.exec("CREATE TRIGGER reject_audit BEFORE INSERT ON audit_integrity BEGIN SELECT RAISE(ABORT,'audit fixture'); END");assert.throws(()=>s.event(null,'dlp.block',{}),/audit fixture/);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,0);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createAuditIntegrityController}=createRequire(import.meta.url)('../apps/server/public/audit-integrity-admin.js');
|
||||
function fixture(){let principal={sub:'one',role:'analyst',permissions:['audit.read']};const calls=[],renders=[];const view=createAuditIntegrityController({principal:()=>principal,request:path=>new Promise((resolve,reject)=>calls.push({path,resolve,reject})),render:(...args)=>renders.push(args),clear:()=>renders.push(['clear'])});return {view,calls,renders,change:p=>{principal=p;view.permissions();}};}
|
||||
const result={valid:true,authenticated:false,externallyAnchored:false,head:{sequence:1,hash:'a'.repeat(64)}};
|
||||
test('verification requires an explicit action and preserves unsigned/unanchored distinctions',async()=>{const f=fixture();f.view.permissions();assert.equal(f.calls.length,0);const pending=f.view.verify();assert.equal(f.calls[0].path,'/api/admin/audit/integrity');f.calls[0].resolve(result);await pending;assert.deepEqual(f.renders.at(-1),['result',result]);});
|
||||
for(const change of ['identity','permission','reset'])test(`late integrity results are suppressed after ${change}`,async()=>{const f=fixture(),pending=f.view.verify();if(change==='reset')f.view.reset();else f.change({sub:change==='identity'?'two':'one',role:'analyst',permissions:change==='permission'?[]:['audit.read']});f.calls[0].resolve(result);await pending;assert.deepEqual(f.renders.at(-1),['clear']);});
|
||||
test('backend error details and malformed integrity claims are suppressed',async()=>{const f=fixture();let pending=f.view.verify();f.calls[0].reject(Error('secret'));await pending;assert.deepEqual(f.renders.at(-1),['error']);pending=f.view.verify();f.calls[1].resolve({valid:true});await pending;assert.deepEqual(f.renders.at(-1),['error']);});
|
||||
test('unprivileged identity cannot trigger verification',async()=>{const f=fixture();f.change(null);await f.view.verify();assert.equal(f.calls.length,0);});
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {initializeAuditIntegrity} from '../apps/server/audit-integrity.mjs';
|
||||
function setup(legacy=false){const db=new DatabaseSync(':memory:');db.exec('CREATE TABLE events(id INTEGER PRIMARY KEY,time TEXT,device_id TEXT,kind TEXT,metadata TEXT)');if(legacy)db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(1,'2026-01-01',null,'old','{}');const audit=initializeAuditIntegrity(db);function insert(id){db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(id,'2026-01-01','device','test','{"bytes":4}');audit.append(db.prepare('SELECT * FROM events WHERE id=?').get(id));}return {db,audit,insert};}
|
||||
test('audit detects event mutations and returns reconstructable proof',()=>{const {db,audit,insert}=setup();insert(1);assert.equal(audit.verify().valid,true);assert.equal(audit.verify().authenticated,false);assert.equal(audit.entries({}).entries[0].type,'event');db.exec("UPDATE events SET metadata='{}' WHERE id=1");assert.ok(audit.verify().errors.includes('event-digest'));db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'audit.retention-applied','{}');audit.append(db.prepare('SELECT * FROM events WHERE id=2').get());assert.throws(()=>audit.authorizePrune([1],2),/changed/);db.close();});
|
||||
test('unauthorized deletion and anchored tail truncation are detected',()=>{const {db,audit,insert}=setup();insert(1);insert(2);const expectedHead=audit.head();db.exec('DELETE FROM events WHERE id=2');assert.ok(audit.verify().errors.includes('missing-event'));db.exec('DELETE FROM audit_integrity WHERE seq=2');assert.ok(audit.verify({expectedHead}).errors.includes('anchor-mismatch'));db.close();});
|
||||
test('authorized retention keeps digest verification without retaining plaintext',()=>{const {db,audit,insert}=setup();insert(1);db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'audit.retention-applied','{}');audit.append(db.prepare('SELECT * FROM events WHERE id=2').get());audit.authorizePrune([1],2);db.exec('DELETE FROM events WHERE id=1');assert.equal(audit.verify().valid,true);assert.equal(audit.entries({}).entries[2].body.includes('bytes'),false);db.close();});
|
||||
test('migration explicitly marks legacy and restart does not backfill orphan events',()=>{const {db,audit}=setup(true);assert.equal(audit.verify().legacyEvents,1);db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(2,'now',null,'orphan','{}');assert.throws(()=>initializeAuditIntegrity(db),/verification failed/);assert.ok(audit.verify().errors.includes('uncovered-events'));assert.equal(audit.head().sequence,1);db.close();});
|
||||
test('chain-body mutation, sequence gaps and malformed retention are detected',()=>{const {db,audit,insert}=setup();insert(1);db.exec("UPDATE audit_integrity SET body='{}' WHERE seq=1");assert.equal(audit.verify().valid,false);assert.throws(()=>audit.entries({limit:1001}));db.close();});
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
test('ML-DSA-87 signatures authenticate entries and require protected signer on restart',()=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-audit-test-')),file=join(dir,'key.pem'),keys=generateKeyPairSync('ml-dsa-87');let db;try{writeFileSync(file,keys.privateKey.export({type:'pkcs8',format:'pem'}),{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_AUDIT_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_AUDIT_KEY:file},stdio:'pipe'});}db=new DatabaseSync(':memory:');db.exec('CREATE TABLE events(id INTEGER PRIMARY KEY,time TEXT,device_id TEXT,kind TEXT,metadata TEXT)');const audit=initializeAuditIntegrity(db,{signingKeyFile:file});db.prepare('INSERT INTO events VALUES(?,?,?,?,?)').run(1,'now',null,'test','{}');audit.append(db.prepare('SELECT * FROM events').get());const trustedPublicKey=keys.publicKey.export({type:'spki',format:'pem'}).toString(),expectedHead=audit.head();assert.equal(audit.verify({trustedPublicKey,expectedHead}).externallyAnchored,true);assert.equal(audit.verify().authenticated,true);assert.throws(()=>initializeAuditIntegrity(db),/unavailable/);assert.equal(initializeAuditIntegrity(db,{signingKeyFile:file}).verify().valid,true);db.exec("UPDATE audit_integrity SET signature='AAAA' WHERE type='event'");assert.ok(audit.verify().errors.includes('signature'));const body=JSON.parse(db.prepare("SELECT body FROM audit_integrity WHERE type='event'").get().body);body.legacy=true;db.prepare("UPDATE audit_integrity SET body=?,signature=NULL,public_key=NULL WHERE type='event'").run(JSON.stringify(body));assert.ok(audit.verify().errors.includes('legacy-after-seal'));db.exec('DELETE FROM audit_integrity');assert.ok(audit.verify().errors.includes('missing-baseline-seal'));db.exec('DELETE FROM events');assert.throws(()=>initializeAuditIntegrity(db,{signingKeyFile:file}),/verification failed/);assert.equal(db.prepare('SELECT count(*) AS n FROM audit_integrity').get().n,0);}finally{db?.close();rmSync(dir,{recursive:true,force:true});}});
|
||||
@@ -10,30 +10,30 @@ const old='2020-01-01T00:00:00.000Z';
|
||||
test('retention preserves pending export, incident evidence and replay deduplication',t=>{
|
||||
const s=fixture(t);s.db.exec('CREATE TABLE IF NOT EXISTS incident_cases(id TEXT PRIMARY KEY,event_id INTEGER REFERENCES events(id))');
|
||||
for(let i=0;i<4;i++)s.event('device','dlp.block',{ruleId:'rule'});
|
||||
s.db.prepare('UPDATE events SET time=?').run(old);
|
||||
|
||||
s.db.prepare('INSERT INTO incident_cases(id,event_id) VALUES(?,?)').run('case',2);
|
||||
s.db.prepare('INSERT INTO device_events VALUES(?,?)').run('device','replay-id');
|
||||
const p=pruneAuditEvents(s,{days:30,exportedThrough:3});assert.equal(p.eligible,2);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,4);
|
||||
const r=pruneAuditEvents(s,{days:30,exportedThrough:3,dryRun:false});assert.equal(r.eligible,2);
|
||||
const p=pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,exportedThrough:3});assert.equal(p.eligible,2);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,4);
|
||||
const r=pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,exportedThrough:3,dryRun:false});assert.equal(r.eligible,2);
|
||||
assert.deepEqual(s.db.prepare('SELECT id FROM events ORDER BY id').all().map(x=>x.id),[2,4,5]);
|
||||
assert.equal(s.db.prepare('SELECT count(*) n FROM device_events').get().n,1);
|
||||
assert.equal(s.db.prepare('SELECT kind FROM events WHERE id=5').get().kind,'audit.retention-applied');
|
||||
});
|
||||
test('batch and SIEM boundaries never orphan a Sigma correlation',t=>{
|
||||
const s=fixture(t),p=s.get('policy');p.security.sigma.rules=[{id:'r',title:'Rule',level:'high',detection:{selection:{kind:'dlp.block'},condition:'selection'}}];s.put('policy',p);s.event('d','dlp.block',{});s.db.prepare('UPDATE events SET time=?').run(old);
|
||||
assert.equal(pruneAuditEvents(s,{days:30,limit:1,exportedThrough:2,dryRun:false}).eligible,0);
|
||||
assert.equal(pruneAuditEvents(s,{days:30,exportedThrough:1,dryRun:false}).eligible,0);
|
||||
assert.equal(pruneAuditEvents(s,{days:30,exportedThrough:2,dryRun:false}).eligible,2);
|
||||
const s=fixture(t),p=s.get('policy');p.security.sigma.rules=[{id:'r',title:'Rule',level:'high',detection:{selection:{kind:'dlp.block'},condition:'selection'}}];s.put('policy',p);s.event('d','dlp.block',{});
|
||||
assert.equal(pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,limit:1,exportedThrough:2,dryRun:false}).eligible,0);
|
||||
assert.equal(pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,exportedThrough:1,dryRun:false}).eligible,0);
|
||||
assert.equal(pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,exportedThrough:2,dryRun:false}).eligible,2);
|
||||
assert.equal(s.db.prepare('SELECT count(*) n FROM alerts').get().n,0);
|
||||
});
|
||||
test('missing cursor fails closed and retention requires explicit deployment opt-in',t=>{
|
||||
const s=fixture(t);s.event(null,'policy.published',{});s.db.prepare('UPDATE events SET time=?').run(old);
|
||||
const s=fixture(t);s.event(null,'policy.published',{});
|
||||
assert.throws(()=>pruneAuditEvents(s,{days:30}),/cursor/);
|
||||
const off=startAuditRetention(s,{status:()=>({cursor:1})});off.run();assert.equal(off.status().enabled,false);assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,1);off.stop();
|
||||
});
|
||||
test('failed deletion rolls back the whole retention operation',t=>{
|
||||
const s=fixture(t);s.event(null,'policy.published',{});s.db.prepare('UPDATE events SET time=?').run(old);
|
||||
const s=fixture(t);s.event(null,'policy.published',{});
|
||||
s.db.exec("CREATE TRIGGER retention_reject BEFORE INSERT ON events WHEN NEW.kind='audit.retention-applied' BEGIN SELECT RAISE(ABORT,'fixture'); END");
|
||||
assert.throws(()=>pruneAuditEvents(s,{days:30,exportedThrough:1,dryRun:false}),/fixture/);
|
||||
assert.throws(()=>pruneAuditEvents(s,{now:Date.now()+60*86400000,days:30,exportedThrough:1,dryRun:false}),/fixture/);
|
||||
assert.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,1);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
function fixture(){const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,hidden:false,files:[],addEventListener(type,fn){this[type]=fn;},removeAttribute(key){delete this[key];}});return fields.get(id);};const policy={brand:{name:'Example',color:'#123456',logoDataUrl:'data:image/png;base64,synthetic'},startPage:{title:'Team',subtitle:'Hello',newsEnabled:true,feedUrl:'https://news.example/feed'}};const context=vm.createContext({$:get,policy,fill(){},draft:()=>({brand:{name:'Edited',color:'#abcdef'},browser:{}}),applyOperatorPermissions(){},independentApprovalRequired:true,currentPrincipal:null,task:fn=>fn,updateDraftStatus(){},Uint8Array});vm.runInContext(readFileSync(new URL('../apps/server/public/branding-admin.js',import.meta.url),'utf8'),context);return {context,get};}
|
||||
test('company draft retains logo during name edits and removes it explicitly',()=>{const {context,get}=fixture();assert.equal(context.draft().brand.logoDataUrl,'data:image/png;base64,synthetic');assert.equal(context.draft().brand.name,'Edited');assert.equal(context.draft().startPage.feedUrl,'https://news.example/feed');get('brand-logo-remove').click();assert.equal(context.draft().brand.logoDataUrl,undefined);assert.equal(get('brand-logo-preview').hidden,true);get('startpage-news-enabled').checked=false;get('startpage-feed-url').value='';assert.equal(context.draft().startPage.newsEnabled,false);assert.equal(context.draft().startPage.feedUrl,undefined);});
|
||||
test('auditors cannot edit company policy; independent operators can prepare drafts',()=>{const {context,get}=fixture();context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('brand-logo-file').disabled,true);assert.equal(get('startpage-feed-url').disabled,true);context.applyOperatorPermissions({role:'operator',permissions:['policy.prepare']});assert.equal(get('brand-logo-file').disabled,false);});
|
||||
test('logo decode rejects unsupported, oversized and forged PNG inputs before rasterization',async()=>{const {context}=fixture();await assert.rejects(context.encodeCompanyLogo({type:'image/svg+xml',size:10}),/PNG/);await assert.rejects(context.encodeCompanyLogo({type:'image/png',size:131073}),/128 KiB/);await assert.rejects(context.encodeCompanyLogo({type:'image/png',size:8,arrayBuffer:async()=>new Uint8Array(8).buffer}),/not a PNG/);});
|
||||
import {StartPageSchema} from '../packages/core/branding.mjs';
|
||||
test('admin start-page draft uses the signed policy root rather than unsupported browser settings',()=>{const {context}=fixture(),p=context.draft();assert.equal(p.browser.startPage,undefined);assert.equal(StartPageSchema.parse(p.startPage).feedUrl,'https://news.example/feed');});
|
||||
@@ -0,0 +1,4 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {BrandSchema,StartPageSchema} from '../packages/core/branding.mjs';import {defaultPolicy,verifyPolicy} from '../packages/core/policy.mjs';import {createApp} from '../apps/server/app.mjs';import {openStore,digest} from '../apps/server/store.mjs';import {mkdtempSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';
|
||||
const logo='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=';
|
||||
test('company PNG and feed policy reject active content and preserve conservative defaults',()=>{assert.equal(BrandSchema.parse({name:'Example',color:'#137c66',logoDataUrl:logo}).logoDataUrl,logo);for(const v of ['https://bad/logo.svg','data:image/svg+xml,<svg/>','data:image/png;base64,AAAA'])assert.throws(()=>BrandSchema.parse({name:'Example',color:'#137c66',logoDataUrl:v}));assert.equal(StartPageSchema.parse({}).newsEnabled,false);assert.throws(()=>StartPageSchema.parse({newsEnabled:true}));assert.throws(()=>StartPageSchema.parse({newsEnabled:true,feedUrl:'http://company/feed'}));assert.equal(StartPageSchema.parse({newsEnabled:true,feedUrl:'https://company.example/feed?List=42'}).newsEnabled,true);});
|
||||
test('branding rolls out in signed device policy and news stays device authenticated',async t=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-branding-')),store=openStore(dir),adminToken='synthetic-admin-credential-at-least-thirty-two',deviceToken='synthetic-device-credential-at-least-thirty-two',config={publicUrl:'http://127.0.0.1',secure:false,adminToken};store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('3b19cb2b-852f-4df7-bf88-bb16d164d130','Fixture','win32','User',digest(deviceToken),'now');const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));const base=`http://127.0.0.1:${server.address().port}`;t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();if(!resolve(dir).startsWith(resolve(tmpdir())+sep))throw Error('Unsafe cleanup');rmSync(dir,{recursive:true,force:true});});const p=defaultPolicy();p.version=2;p.brand={name:'Example Company',color:'#224466',logoDataUrl:logo};p.startPage={title:'Company portal',subtitle:'Welcome to your company',newsEnabled:false};const publication=await fetch(base+'/api/admin/policy',{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(p)});assert.equal(publication.status,200);const signed=store.envelope('3b19cb2b-852f-4df7-bf88-bb16d164d130');const delivered=verifyPolicy(signed,store.publicKey());assert.equal(delivered.brand.logoDataUrl,logo);assert.equal(delivered.startPage.title,'Company portal');assert.equal((await fetch(base+'/api/device/start-page/news')).status,401);const news=await fetch(base+'/api/device/start-page/news',{headers:{authorization:`Bearer ${deviceToken}`}});assert.equal(news.status,200);assert.deepEqual(await news.json(),{policyVersion:2,news:[]});});
|
||||
@@ -0,0 +1,22 @@
|
||||
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 shipped=source.slice(source.indexOf('function auditAgent(event)'),source.indexOf('function applyPolicy(envelope)'));
|
||||
function fixture(){const calls=[],context=vm.createContext({require:()=>require('../packages/core/agent-audit.cjs'),audit:(...args)=>calls.push(args)});vm.runInContext(shipped,context);return {run:context.auditAgent,calls};}
|
||||
test('native agent audit preserves typed scope/session but never status, arguments or credentials',()=>{
|
||||
const f=fixture(),sessionId='00000000-0000-4000-8000-000000000001';
|
||||
f.run({action:'read',scope:'browser:read',outcome:'completed',sessionId,status:200,args:{password:'PRIVATE_ARGUMENT'},token:'PRIVATE_CAPABILITY'});
|
||||
const [kind,rule,,,,,agent]=f.calls[0];assert.equal(kind,'agent.action');assert.equal(rule,'read:completed');assert.equal(agent.scope,'browser:read');assert.equal(agent.sessionId,sessionId);
|
||||
assert.equal(JSON.stringify(f.calls).includes('PRIVATE_'),false);assert.deepEqual(Object.keys(agent).sort(),['action','outcome','scope','sessionId']);
|
||||
});
|
||||
test('failed and adapter-denied actions retain truthful outcomes and malformed sessions cannot suppress audit',()=>{
|
||||
const f=fixture();f.run({action:'type',outcome:'failed',status:500,sessionId:'00000000-0000-f000-0000-000000000001'});
|
||||
assert.equal(f.calls[0][0],'agent.denied');assert.equal(f.calls[0][6].outcome,'failed');assert.equal(f.calls[0][6].sessionId,undefined);
|
||||
f.run({action:'agent.read',outcome:'blocked'});assert.equal(f.calls[1][6].action,'read');assert.equal(f.calls[1][6].outcome,'denied');
|
||||
f.run({action:'agent-request',outcome:'denied',status:401});assert.equal(f.calls[2][6].scope,'agent:request');
|
||||
});
|
||||
|
||||
test('browser retains authenticated individual actor without credential secrets',()=>{const f=fixture();f.run({action:'read',outcome:'completed',principalId:'security-agent',generation:3,token:'PRIVATE_TOKEN'});const agent=f.calls[0][6];assert.equal(agent.principalId,'security-agent');assert.equal(agent.credentialGeneration,3);assert.equal(JSON.stringify(f.calls).includes('PRIVATE_TOKEN'),false);});
|
||||
@@ -0,0 +1,25 @@
|
||||
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 shipped=source.slice(source.indexOf('async function reconcileAgentPolicy()'),source.indexOf('async function pairIndividualAgent'));
|
||||
function fixture(){const events=[],grants=[{id:'reader',name:'Reader',generation:2,enabled:true,expiresAt:new Date(Date.now()+60000).toISOString(),scopes:['read'],allowedHosts:[]}];const service={revoke:()=>events.push('revoke'),close:async()=>events.push('close'),reconcilePrincipals:g=>events.push(g)};const context=vm.createContext({agentGrants:require('../apps/browser/agent-grants.cjs'),policy:{agents:{identityMode:'individual',enabled:true,grants},browser:{settings:{hiddenSections:[],lockedSections:[]}}},device:{},saveDevice:()=>events.push('save'),agentService:service,agentServiceMode:'individual',ready:false,fs:{unlinkSync:()=>{}},agentFile:()=>'/synthetic',featureAllowed:()=>false,managedSettings:()=>({})});vm.runInContext(shipped,context);return {context,events,service,run:context.reconcileAgentPolicy};}
|
||||
test('accepted policy reconciles individual grants while temporary readiness pause preserves peer service',async()=>{const f=fixture();await f.run();assert.equal(f.context.agentService,f.service);assert.equal(f.events[0],'save');assert.equal(f.events[1][0].generation,2);assert.equal(f.events.includes('revoke'),false);f.context.policy.agents.grants[0].generation=1;await f.run();assert.equal(f.events.at(-1)[0].enabled,false);assert.equal(f.context.device.agentGenerationFloor.reader,2);});
|
||||
test('mode changes and centrally hidden agents revoke old broker before reissue',async()=>{for(const mode of ['legacy','hidden']){const f=fixture();if(mode==='legacy')f.context.policy.agents.identityMode='legacy';else f.context.policy.browser.settings.hiddenSections=['agents'];await f.run();assert.ok(f.events.includes('revoke'));assert.ok(f.events.includes('close'));assert.equal(f.context.agentService,null);}});
|
||||
|
||||
test('stored library proposal cannot be applied by an actor excluded from its destinations',async()=>{
|
||||
const helper=source.slice(source.indexOf('function assertAgentLibraryDestinations('),source.indexOf('function getLibraryOrganizer('));
|
||||
const callback=source.split(/\r?\n/).find(line=>line.includes(' assertCurrent:(snapshot,')).trim().slice('assertCurrent:'.length,-1);
|
||||
let actor={principalId:'planner'},commits=0;
|
||||
const policy={version:1},identity={},snapshot={policy,identity,epoch:'stable',bookmarkRevision:0,bookmarks:[{id:'b',url:'https://excluded.example/private'}],tabs:[{id:'t',url:'https://excluded.example/work'}]};
|
||||
const context=vm.createContext({agentActorStorage:{getStore:()=>actor},actorDestinationAllowed:url=>actor.principalId==='planner'||new URL(url).hostname==='allowed.example',requireLibraryAI:()=>{},policy,device:identity,nativeLibraryEpoch:()=> 'stable',personalBookmarks:{revision:0}});
|
||||
vm.runInContext(helper+'\n globalThis.checkSnapshot='+callback+';',context);
|
||||
const manager=require('../apps/browser/library-organization.cjs').createLibraryOrganization({capture:async()=>snapshot,assertCurrent:context.checkSnapshot,inspect:async()=>{},propose:async()=>({proposalId:'shared-proposal',plan:{bookmarks:[{id:'b',folder:'Work'}],tabs:[{id:'t',group:'Work'}]}}),commit:async()=>{commits++;return {version:1};},applyTabs:()=>{},audit:async()=>{}});
|
||||
await manager.plan();actor={principalId:'restricted-applier'};
|
||||
await assert.rejects(manager.apply({proposalId:'shared-proposal',confirm:true}),/Agent destination denied/);
|
||||
assert.equal(commits,0);
|
||||
});
|
||||
|
||||
test('individual network gates refuse ownerless traffic even after tab authority is cleared',()=>{const shippedOwner=source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed'));const c=vm.createContext({policy:{agents:{identityMode:'individual'}}});vm.runInContext(shippedOwner,c);assert.equal(c.agentRequestOwnerAllowed(null),false);assert.equal(c.agentRequestOwnerAllowed({guarded:false}),false);assert.equal(c.agentRequestOwnerAllowed({guarded:true,security:{agentAuthority:null}}),true);c.policy.agents.identityMode='legacy';assert.equal(c.agentRequestOwnerAllowed(null),true);assert.ok(source.includes('||!agentRequestOwnerAllowed(source)||'));});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {DomainCategorySchema} from '../packages/core/user-profiles.mjs';
|
||||
const source=readFileSync(new URL('../apps/server/public/profile-admin.js',import.meta.url),'utf8');
|
||||
function fixture(){const fields=new Map();const get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,dataset:{},querySelectorAll:()=>[],addEventListener(){},replaceChildren(){}});return fields.get(id);};const context=vm.createContext({$:get,task:fn=>fn,currentPrincipal:null,structuredClone,document:{},independentApprovalRequired:false});vm.runInContext(source,context);return {context,get};}
|
||||
test('category validation matches backend host, identifier, duplicate and bounds constraints',()=>{const {context}=fixture();const valid=[{id:'social',name:'Social',hosts:['*.social.example','intranet']}];assert.equal(vm.runInContext(`validateCategoryDraft(${JSON.stringify(valid)}).length`,context),1);DomainCategorySchema.parse(valid[0]);for(const change of [{id:'bad id'},{name:''},{hosts:['https://host.example']},{hosts:['host.example:443']},{hosts:['x','x']},{hosts:['a'.repeat(254)]}]){const category={...valid[0],...change};assert.equal(DomainCategorySchema.safeParse(category).success,false);assert.throws(()=>vm.runInContext(`validateCategoryDraft(${JSON.stringify([category])})`,context));}assert.throws(()=>vm.runInContext(`validateCategoryDraft(${JSON.stringify([valid[0],valid[0]])})`,context));});
|
||||
test('dirty state retains original CAS base and referenced category removal remains locked',()=>{const {context,get}=fixture();vm.runInContext("categoryDraftVersion=12;profileState={version:13,profiles:[{name:'Employees',blockedCategoryIds:['social']}],categories:[]};categoryChanged()",context);assert.equal(vm.runInContext('categoryDraftVersion',context),12);assert.equal(vm.runInContext('categoryDraftDirty',context),true);assert.equal(vm.runInContext("categoryReferences('social').length",context),1);assert.match(get('profile-category-status').textContent,/v12/);assert.match(source,/expectedVersion:categoryDraftVersion/);assert.match(source,/if\(!categoryDraftDirty\)/);});
|
||||
test('structured accessible controls replace mandatory JSON and retain explicit publication',()=>{const html=readFileSync(new URL('../apps/server/public/index.html',import.meta.url),'utf8');assert.ok(html.includes('id="profile-category-editor"'));assert.ok(html.includes('id="profile-category-status" role="status"'));assert.ok(!html.includes('id="profile-category-json"'));assert.match(source,/input.readOnly=profileState.categories.some/);assert.match(source,/button.dataset.categoryReferenced==='true'/);});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createCompanyNewsReader,parseCompanyNews,publicNewsAddress} from '../apps/server/company-news.mjs';
|
||||
const policy={version:1,startPage:{newsEnabled:true,feedUrl:'https://news.example/feed'}};
|
||||
const rss=Buffer.from('<rss><channel><item><title><![CDATA[<b>Company news</b> & update]]></title><link>https://intranet.example/article</link><pubDate>2026-10-13</pubDate></item><item><title>Bad URL</title><link>javascript:alert(1)</link></item></channel></rss>');
|
||||
test('RSS and Atom produce bounded plain text and safe links',()=>{
|
||||
assert.deepEqual(parseCompanyNews(rss),[{title:'Company news & update',url:'https://intranet.example/article',publishedAt:'2026-10-13T00:00:00.000Z'}]);
|
||||
assert.equal(parseCompanyNews(Buffer.from('<feed><entry><title>Announcement</title><link href="https://company.example/story" rel="alternate"/></entry></feed>'))[0].title,'Announcement');
|
||||
assert.throws(()=>parseCompanyNews(Buffer.from('<!DOCTYPE rss [<!ENTITY x SYSTEM "file:///secret">]><rss/>')));
|
||||
assert.throws(()=>parseCompanyNews(Buffer.from('<rss><item></rss>')));
|
||||
assert.throws(()=>parseCompanyNews(Buffer.alloc(524289)));
|
||||
});
|
||||
test('news disabled does not resolve or request anything',async()=>{
|
||||
const read=createCompanyNewsReader({lookup:()=>{throw Error('Must not resolve');}});
|
||||
assert.deepEqual(await read({version:1,startPage:{newsEnabled:false}}),[]);
|
||||
});
|
||||
test('public DNS is pinned and feed is cached without article requests',async()=>{
|
||||
let calls=0;const read=createCompanyNewsReader({lookup:async()=>[{address:'8.8.8.8',family:4}],request:async(url,options)=>{calls++;assert.equal(options.address,'8.8.8.8');assert.equal(url.href,policy.startPage.feedUrl);return rss;}});
|
||||
assert.equal((await read(policy)).length,1);await read(policy);assert.equal(calls,1);
|
||||
});
|
||||
test('SSRF rejects private, loopback, mapped IPv6, mixed DNS and numeric URLs',async()=>{
|
||||
for(const address of ['10.0.0.8','127.0.0.1','::1','::ffff:127.0.0.1','169.254.169.254']){
|
||||
assert.equal(publicNewsAddress(address),false);
|
||||
const read=createCompanyNewsReader({lookup:async()=>[{address,family:address.includes(':')?6:4}],request:()=>{throw Error('Must not fetch');}});await assert.rejects(read(policy));
|
||||
}
|
||||
const mixed=createCompanyNewsReader({lookup:async()=>[{address:'8.8.8.8',family:4},{address:'10.0.0.8',family:4}]});await assert.rejects(mixed(policy));
|
||||
const read=createCompanyNewsReader();for(const feedUrl of ['http://news.example/feed','https://user:[email protected]/feed','https://127.0.0.1/feed'])await assert.rejects(read({...policy,startPage:{...policy.startPage,feedUrl}}));
|
||||
});
|
||||
test('exact corporate allowlist permits private DNS but never loopback',async()=>{
|
||||
const read=createCompanyNewsReader({allowedHosts:'news.example',lookup:async()=>[{address:'10.0.0.8',family:4}],request:async()=>rss});assert.equal((await read(policy)).length,1);
|
||||
const loop=createCompanyNewsReader({allowedHosts:'news.example',lookup:async()=>[{address:'127.0.0.1',family:4}]});await assert.rejects(loop(policy));
|
||||
const suffix=createCompanyNewsReader({allowedHosts:'example',lookup:async()=>[{address:'10.0.0.8',family:4}]});await assert.rejects(suffix(policy));
|
||||
});
|
||||
test('failed feeds are not cached',async()=>{
|
||||
let calls=0;const read=createCompanyNewsReader({lookup:async()=>[{address:'8.8.8.8',family:4}],request:async()=>{calls++;throw Error('Offline');}});await assert.rejects(read(policy));await assert.rejects(read(policy));assert.equal(calls,2);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {normalizeCollectorBatch} from '../scripts/debug-collector-batch.mjs';
|
||||
const span={traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef',name:'synthetic',startTimeUnixNano:'1000000000000000000',endTimeUnixNano:'1000000000001000000'};
|
||||
const batch=value=>({resourceSpans:[{scopeSpans:[{spans:[value]}]}]});
|
||||
test('collector diagnostics normalize valid timestamps, correlation and empty batches before mutation',()=>{assert.equal(normalizeCollectorBatch('traces',batch(span)).spans[0].durationMs,1);assert.deepEqual(normalizeCollectorBatch('traces',{}).spans,[]);assert.equal(normalizeCollectorBatch('logs',{resourceLogs:[]}).logs,0);assert.equal(normalizeCollectorBatch('metrics',{}).metrics,1);});
|
||||
test('collector rejects unsafe durations, non-array hierarchy and invalid diagnostic identities',()=>{for(const value of [{...span,endTimeUnixNano:'100000000000000000'}, {...span,startTimeUnixNano:'0',endTimeUnixNano:String(BigInt(Number.MAX_SAFE_INTEGER)+1n)}, {...span,spanId:'0000000000000000'}, {...span,parentSpanId:123}, {...span,name:null}])assert.throws(()=>normalizeCollectorBatch('traces',batch(value)));for(const payload of [{resourceSpans:{}},{resourceSpans:[{scopeSpans:{}}]},{resourceSpans:[{scopeSpans:[{spans:{}}]}]}])assert.throws(()=>normalizeCollectorBatch('traces',payload));assert.throws(()=>normalizeCollectorBatch('metrics',{resourceMetrics:[{scopeMetrics:[{metrics:{}}]}]}));});
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync,readdirSync,readFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {spawn} from 'node:child_process';import {createServer} from 'node:net';
|
||||
test('actual Windows protected collector rotates encrypted exports and rejects oversized storage without acknowledgement',{skip:process.platform!=='win32'},async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'safetynet-collector-http-'));const probe=createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
|
||||
const child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1'}});
|
||||
let output='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);t.after(async()=>{if(child.exitCode===null){child.kill();await new Promise(r=>child.once('exit',r));}assert.ok(root.startsWith(join(tmpdir(),'safetynet-collector-http-')));rmSync(root,{recursive:true,force:true});});
|
||||
const base=`http://127.0.0.1:${port}`;let health;
|
||||
for(let i=0;i<80;i++){if(child.exitCode!==null)throw Error('Collector test process failed: '+output);try{health=await (await fetch(base+'/health')).json();break;}catch{await new Promise(r=>setTimeout(r,100));}}
|
||||
assert.ok(health);const marker='SYNTHETIC_COLLECTOR_ROTATION';const post=body=>fetch(base+'/v1/logs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
||||
for(let i=0;i<12;i++)assert.equal((await post({resourceLogs:[{scopeLogs:[{logRecords:[{body:{stringValue:marker}}]}]}]})).status,200);
|
||||
health=await (await fetch(base+'/api/summary')).json();assert.equal(health.requests,12);assert.equal(health.counts.logs,12);assert.ok(health.rotations>1);assert.ok(health.droppedSegments>0);assert.equal(health.retiredSegments,1);assert.ok(health.activeBytes+health.retiredBytes<=2048);
|
||||
for(const name of readdirSync(root).filter(n=>n.endsWith('.ndjson'))){const content=readFileSync(join(root,name),'utf8');assert.equal(content.includes(marker),false);for(const line of content.trim().split('\n')){const envelope=JSON.parse(line);assert.equal(envelope.v,1);assert.ok(envelope.nonce&&envelope.tag&&envelope.ciphertext);}}
|
||||
assert.equal((await post({large:'x'.repeat(2048)})).status,507);health=await (await fetch(base+'/health')).json();assert.equal(health.requests,12);assert.equal(health.rejectedWrites,1);
|
||||
const before=structuredClone(health);const tracePost=body=>fetch(base+'/v1/traces',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
||||
const span={traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef',name:'synthetic.stage',startTimeUnixNano:'10',endTimeUnixNano:'11'};
|
||||
for(const bad of [{...span,endTimeUnixNano:'9'},{...span,startTimeUnixNano:'18446744073709551616'},{...span,traceId:123},{...span,endTimeUnixNano:'oops'},{...span,attributes:{}}])assert.equal((await tracePost({resourceSpans:[{scopeSpans:[{spans:[bad]}]}]})).status,400);
|
||||
assert.equal((await tracePost({resourceSpans:{}})).status,400);assert.equal((await post({resourceLogs:[{scopeLogs:{}}]})).status,400);
|
||||
health=await (await fetch(base+'/health')).json();for(const field of ['requests','storedBytes','activeBytes','legacyBytes','retiredBytes','rotations','droppedSegments','droppedBytes','rejectedWrites','retainedSpans'])assert.equal(health[field],before[field]);assert.deepEqual(health.counts,before.counts);
|
||||
assert.equal((await tracePost({resourceSpans:[]})).status,200);assert.equal((await tracePost({resourceSpans:[{scopeSpans:[{spans:[span]}]}]})).status,200);const trace=await (await fetch(base+'/api/trace/'+span.traceId)).json();assert.equal(trace.spans[0].durationMs,0.000001);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,writeFileSync,readFileSync,readdirSync,rmSync,mkdirSync,linkSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';
|
||||
import {collectorStorage} from '../scripts/debug-collector-storage.mjs';
|
||||
function fixture(t){const root=mkdtempSync(join(tmpdir(),'safetynet-collector-rotation-'));t.after(()=>{assert.ok(resolve(root).startsWith(resolve(tmpdir())+'\\safetynet-collector-rotation-')||resolve(root).startsWith(resolve(tmpdir())+'/safetynet-collector-rotation-'));rmSync(root,{recursive:true,force:true});});return root;}
|
||||
test('collector exact budget rotates before append and pins original encrypted archive',t=>{const root=fixture(t),old='encrypted-old-record\n';writeFileSync(join(root,'signals.encrypted.ndjson'),old);const store=collectorStorage(root,{segmentBytes:1024,retiredSegments:2});store.append('x'.repeat(1024));assert.equal(readFileSync(join(root,'signals.legacy.encrypted.ndjson'),'utf8'),old);assert.equal(store.summary().storedBytes,1024);assert.equal(store.summary().rotations,1);});
|
||||
test('rolling developer segments are bounded across restart while legacy remains pinned',t=>{const root=fixture(t);let store=collectorStorage(root,{segmentBytes:1024,retiredSegments:2});for(let i=0;i<8;i++)store.append(String(i).repeat(1024));let summary=store.summary();assert.equal(summary.retiredSegments,2);assert.equal(summary.droppedSegments,4);assert.equal(summary.activeBytes+summary.retiredBytes,3072);assert.equal(readFileSync(join(root,'signals.legacy.encrypted.ndjson'),'utf8'),'0'.repeat(1024));store=collectorStorage(root,{segmentBytes:1024,retiredSegments:2});store.append('z');assert.equal(store.summary().retiredSegments,2);assert.equal(readFileSync(join(root,'signals.legacy.encrypted.ndjson'),'utf8'),'0'.repeat(1024));});
|
||||
test('oversized batch is rejected without writing or retiring archive',t=>{const root=fixture(t),store=collectorStorage(root,{segmentBytes:1024});assert.throws(()=>store.append('x'.repeat(1025)));assert.equal(store.summary().rejectedWrites,1);assert.equal(store.summary().rotations,0);assert.equal(readdirSync(root).length,0);});
|
||||
test('unexpected entry and directory substitution fail closed before deletion or append',t=>{const root=fixture(t),store=collectorStorage(root,{segmentBytes:1024});store.append('x'.repeat(1024));writeFileSync(join(root,'unrelated.txt'),'preserve');assert.throws(()=>store.append('z'));assert.equal(readFileSync(join(root,'unrelated.txt'),'utf8'),'preserve');assert.equal(readFileSync(join(root,'signals.encrypted.ndjson'),'utf8').length,1024);});
|
||||
test('retention refuses unexpected directory masquerading as generated segment',t=>{const root=fixture(t);mkdirSync(join(root,'signals.rolling.1234567890123.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.encrypted.ndjson'));assert.throws(()=>collectorStorage(root,{segmentBytes:1024}));});
|
||||
test('hardlinked encrypted target is refused without altering its other name',t=>{const root=fixture(t),outside=fixture(t),path=join(outside,'preserved');writeFileSync(path,'external-original');linkSync(path,join(root,'signals.encrypted.ndjson'));assert.throws(()=>collectorStorage(root,{segmentBytes:1024}));assert.equal(readFileSync(path,'utf8'),'external-original');});
|
||||
@@ -15,4 +15,28 @@ test('browser diagnostics exports real OTLP parent/child traces and correlated s
|
||||
test('disable and reconfiguration discard queued generation before export',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});diagnostics.event('browser.security',{decision:'block'});diagnostics.configure({enabled:false});await diagnostics.flush();await delay(100);assert.equal(requests.length,0);let calls=0;await diagnostics.span('browser.navigation',{},async()=>{calls++;});assert.equal(calls,1);assert.equal(diagnostics.status().enabled,false);diagnostics.configure(config(endpoint),{policyVersion:9});await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});await diagnostics.flush();assert.equal(spans(requests).length,1);assert.equal(spans(requests)[0].attributes.find(a=>a.key==='safetynet.policy.version').value.intValue,9);diagnostics.configure({...config(endpoint),endpoint:'http://10.0.0.1:4318'});assert.equal(diagnostics.status().enabled,false);});
|
||||
test('diagnostics bounded queues expire old records and never repeat failed security operations',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000});await diagnostics.span('browser.request',{requestType:'xhr'},async()=>{});await delay(1100);await diagnostics.flush();assert.equal(requests.length,0,'Expired diagnostic record exported');diagnostics.configure({...config(endpoint),maxQueueSize:16});for(let i=0;i<50;i++)diagnostics.event('browser.request',{requestType:'xhr'});await diagnostics.flush();assert.ok(logs(requests).length<=50);for(const request of requests)if(request.path.endsWith('/logs'))assert.ok(logs([request]).length<=16,'Export batch exceeded bounded queue');let calls=0;diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250});assert.equal(await diagnostics.span('browser.request',{},async()=>{calls++;return 'security decision';}),'security decision');await diagnostics.flush();assert.equal(calls,1);assert.ok(diagnostics.status().failedExports>=1);});
|
||||
test('diagnostic attributes are typed and never invoke hostile getters',()=>{const input={operation:'read',sessionId:'not-a-uuid',status:'ok',secret:'never',bytes:32};Object.defineProperty(input,'prompt',{enumerable:true,get(){throw Error('getter called');}});assert.deepEqual(attributes(input),{'safetynet.operation':'read','safetynet.status':'ok','safetynet.bytes':32});});
|
||||
|
||||
test('fixed diagnostic reasons export in real OTLP fault spans and correlated logs while injected reasons are discarded',{timeout:15000},async t=>{
|
||||
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));
|
||||
const reasons=['request-source-context-changed','header-source-context-changed','headers-inspection-timeout','request-inspection-timeout','upload-lease-lost','upload-lease-release-failed','invalid-source-owner','policy-changed'];
|
||||
const secret='PASSWORD_REASON_SECRET_MUST_NEVER_EXPORT';
|
||||
for(const reason of reasons)await diagnostics.span('browser.upload.fault',{reason,status:'error'},async()=>diagnostics.event('browser.security',{reason,decision:'block'}));
|
||||
await diagnostics.span('browser.upload.fault',{reason:secret},async()=>diagnostics.event('browser.security',{reason:secret}));
|
||||
await diagnostics.flush();const traceSpans=spans(requests),records=logs(requests);
|
||||
assert.equal(traceSpans.length,reasons.length+1);
|
||||
for(const reason of reasons){
|
||||
const span=traceSpans.find(s=>s.attributes.some(a=>a.key==='safetynet.reason'&&a.value.stringValue===reason));assert.ok(span,reason);assert.equal(span.name,'browser.upload.fault');
|
||||
assert.ok(records.some(log=>log.traceId===span.traceId&&log.spanId===span.spanId&&log.attributes.some(a=>a.key==='safetynet.reason'&&a.value.stringValue===reason)),reason);
|
||||
}
|
||||
assert.equal(traceSpans.filter(s=>s.attributes.some(a=>a.key==='safetynet.reason')).length,reasons.length);
|
||||
assert.equal(JSON.stringify(requests).includes(secret),false);
|
||||
for(const reason of [null,42,{},'upload-lease-lost '+secret,'__proto__'])assert.deepEqual(attributes({reason}),{});
|
||||
const hostile={};Object.defineProperty(hostile,'reason',{enumerable:true,get(){throw Error('getter must not run');}});assert.deepEqual(attributes(hostile),{});
|
||||
});
|
||||
test('OTLP redirects do not forward diagnostics and zero sampling suppresses records',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t);const redirect=createServer((_req,res)=>{res.statusCode=307;res.setHeader('location',endpoint+'/v1/traces');res.end('redirect');}).listen(0,'127.0.0.1');await new Promise(r=>redirect.once('listening',r));t.after(()=>new Promise(r=>{redirect.closeAllConnections();redirect.close(r);}));const diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(`http://127.0.0.1:${redirect.address().port}`));await diagnostics.span('browser.navigation',{},async()=>{});await diagnostics.flush();assert.equal(requests.length,0,'Collector redirect received diagnostic payload');assert.ok(diagnostics.status().failedExports>=1);diagnostics.configure({...config(endpoint),sampleRate:0,debugSampleRate:0});await diagnostics.span('browser.ipc',{operation:'state'},async()=>{assert.deepEqual(diagnostics.correlation(),{});diagnostics.event('browser.security',{decision:'block'});});await diagnostics.flush();assert.equal(requests.length,0);});
|
||||
|
||||
test('inspector timing batches export exact nested spans only inside live debug operations',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const base=Date.now()-100,batch={baseUnixMs:base,secret:'STAGE_SECRET',records:[{id:1,parentId:null,stage:'inspect',startOffsetMs:0,durationMs:80,status:'ok',secret:'STAGE_SECRET'},{id:2,parentId:1,stage:'parse',startOffsetMs:10,durationMs:20,status:'ok'},{id:3,parentId:1,stage:'semantic',startOffsetMs:35,durationMs:40,status:'error'}]};diagnostics.importStages(batch);await diagnostics.span('browser.inspect.local',{},async()=>{diagnostics.importStages(batch);});await diagnostics.flush();const output=spans(requests),outer=output.find(s=>s.name==='browser.inspect.local'),inspect=output.find(s=>s.name==='inspector.inspect'),parse=output.find(s=>s.name==='inspector.parse'),semantic=output.find(s=>s.name==='inspector.semantic');assert.equal(output.length,4);assert.equal(inspect.parentSpanId,outer.spanId);assert.equal(parse.parentSpanId,inspect.spanId);assert.equal(semantic.traceId,outer.traceId);assert.equal(semantic.status.code,2);assert.equal(BigInt(parse.startTimeUnixNano),BigInt(base+10)*1000000n);assert.equal(BigInt(parse.endTimeUnixNano)-BigInt(parse.startTimeUnixNano),20000000n);assert.equal(JSON.stringify(requests).includes('STAGE_SECRET'),false);
|
||||
diagnostics.configure({...config(endpoint),mode:'operational'});await diagnostics.span('browser.inspect.local',{},async()=>diagnostics.importStages(batch));await diagnostics.flush();assert.equal(spans(requests).filter(s=>s.name.startsWith('inspector.')).length,3);
|
||||
});
|
||||
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);
|
||||
});
|
||||
@@ -36,3 +36,17 @@ test('magic-byte executable and mismatch blocks precede scanner, dialog and OS w
|
||||
test('no-scanner downloads require explicit central approval and carry actual safe forensic facts',async()=>{
|
||||
const f=setup();f.context.policy.security.yara.enabled=false;assert.equal((await f.gate(f.input)).reason,'malware-scanner-required');f.context.policy.fileTransfers={download:{allowWithoutMalwareScanner:true}};const v=await f.gate(f.input);assert.equal(v.released,true);assert.equal(v.facts.detectedType,'text');assert.equal(v.facts.size,15);
|
||||
});
|
||||
test('opt-in terminal evidence carries inspected metadata and sanitized source URL',async()=>{
|
||||
const events=[];const f=setup({audit:async(...args)=>events.push(args)});f.context.policy.evidence={enabled:true,captureDownloads:true};
|
||||
assert.equal((await f.gate({...f.input,url:'https://user:[email protected]/file?token=secret#private'})).released,true);
|
||||
assert.equal(events.find(e=>e[0]==='download.scan')[4],undefined);const e=events.find(e=>e[0]==='download.released')[4];
|
||||
assert.equal(e.sourceUrl,'https://example.com/file');assert.equal(e.bytes,15);assert.equal(e.sha256,createHash('sha256').update('benign download').digest('hex'));assert.equal(e.decision,'allow');assert.equal('content' in e,false);
|
||||
});
|
||||
test('disabled, cancelled or stale downloads cannot enqueue protected evidence',async()=>{
|
||||
for(const settings of [{enabled:false,captureDownloads:true},{enabled:true,captureDownloads:false},undefined]){const events=[];const f=setup({audit:async(...args)=>events.push(args)});f.context.policy.evidence=settings;await f.gate(f.input);assert.ok(events.every(e=>!e[4]));}
|
||||
const events=[];const f=setup({choosePath:async()=>null,audit:async(...args)=>events.push(args)});f.context.policy.evidence={enabled:true,captureDownloads:true};assert.equal((await f.gate(f.input)).reason,'user-cancelled');assert.ok(events.every(e=>!e[4]));
|
||||
let current=true;const stale=[];const g=setup({isCurrent:()=>current,scan:async()=>{current=false;return {decision:'block',ruleId:'malware'};},audit:async(...args)=>stale.push(args)});g.context.policy.evidence={enabled:true,captureDownloads:true};await g.gate(g.input);assert.ok(stale.every(e=>!e[4]));
|
||||
});
|
||||
test('malware rejection captures complete blocked facts without filesystem release',async()=>{
|
||||
const events=[];const f=setup({scan:async()=>({decision:'block',ruleId:'malware'}),audit:async(...args)=>events.push(args)});f.context.policy.evidence={enabled:true,captureDownloads:true};assert.equal((await f.gate(f.input)).released,false);assert.equal(events[0][4].decision,'block');assert.equal(events[0][4].ruleId,'malware');assert.equal(f.calls.includes('write'),false);
|
||||
});
|
||||
@@ -38,3 +38,12 @@ test('explicit no antivirus mode still requires complete DLP and never substitut
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'allow',coverage:{complete:true}})})).decision,'allow');
|
||||
const mandatory={...p,security:{yara:{enabled:true}}};assert.equal((await inspection.scanDownloadEnterprise(bytes,mandatory,{inspect:async()=>({decision:'allow',coverage:{complete:true}})})).ruleId,'yara-unavailable');
|
||||
});
|
||||
|
||||
test('incoming document DLP can be explicitly disabled only while malware scanning remains mandatory',async()=>{
|
||||
const p={...policy,fileTransfers:{download:{requireDocumentDlp:false}}};
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,null,async()=>allow())).decision,'allow');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,null,async()=>({decision:'block',ruleId:'icap-malicious-macro'}))).ruleId,'icap-malicious-macro');
|
||||
const disabled={...p,enterprise:{icap:{enabled:false}}};assert.equal((await inspection.scanDownloadEnterprise(bytes,disabled,null)).ruleId,'malware-scanner-required');
|
||||
disabled.fileTransfers.download.allowWithoutMalwareScanner=true;assert.equal((await inspection.scanDownloadEnterprise(bytes,disabled,null)).ruleId,'download-dlp-unavailable');
|
||||
const both={...p,security:{yara:{enabled:true}}};assert.equal((await inspection.scanDownloadEnterprise(bytes,both,null,async()=>allow())).ruleId,'yara-unavailable');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {buildDownloadEvidence,validateDownloadEvidence}=createRequire(import.meta.url)('../apps/browser/evidence-capture.cjs');
|
||||
const policy={evidence:{enabled:true,captureDownloads:true}},input={url:'https://example.com/file?secret=omit#omit',name:'../report.pdf',mime:'Application/PDF; charset=utf8',facts:{size:4,sha256:'a'.repeat(64)},decision:'allow',ruleId:'scanner-allow'};
|
||||
test('metadata builder allowlists and canonicalizes fields',()=>{const p=buildDownloadEvidence({...input,contents:'never stored'},policy);assert.deepEqual(p,{sourceUrl:'https://example.com/file',name:'report.pdf',sha256:'a'.repeat(64),bytes:4,mime:'application/pdf',decision:'allow',ruleId:'scanner-allow'});assert.equal(validateDownloadEvidence({...p,password:'secret'}),false);});
|
||||
test('metadata rejects incomplete facts and recognizable credential paths or names',()=>{for(const extra of [{facts:null},{facts:{size:-1,sha256:'a'.repeat(64)}},{url:'file:///secret'},{url:'https://example.com/password%3Dsecret123'},{name:'api_key=secret123.txt'},{url:'https://example.com/%ZZ'}])assert.equal(buildDownloadEvidence({...input,...extra},policy),null);});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import channel from '../packages/core/policy-channel.cjs';
|
||||
import browser from '../apps/browser/management-channel.cjs';
|
||||
|
||||
const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87');
|
||||
const publicKey=ed.publicKey.export({type:'spki',format:'pem'}),signatureKey=pq.publicKey.export({type:'spki',format:'pem'});
|
||||
const payload={eventId:'4d113278-cabd-4e6d-b4be-022220f7a505',type:'download',payload:{name:'confidential-synthetic-only.xlsx'}};
|
||||
function fixture({legacy=false,result={status:201,body:{id:'synthetic-ack'}},mutateResponse,mutateChallenge}={}){
|
||||
const device={server:'https://synthetic.invalid',id:'device-a',token:'synthetic-token',publicKey,...(!legacy?{channelSignatureKey:signatureKey}:{}),privateEnrollmentFlag:true};
|
||||
const calls=[];let item,saved=0;
|
||||
const request=async(server,route,body,token)=>{
|
||||
assert.equal(server,device.server);assert.equal(token,device.token);calls.push({route,body});
|
||||
if(route==='/api/device/channel/identity')return channel.identity(ed.privateKey,pq.privateKey,signatureKey);
|
||||
if(route==='/api/device/channel/evidence/challenge'){item=channel.evidenceChannel.challenge(device.id,ed.privateKey,pq.privateKey);return mutateChallenge?mutateChallenge(item.bundle):item.bundle;}
|
||||
assert.equal(route,'/api/device/channel/evidence');assert.equal(JSON.stringify(body).includes(payload.payload.name),false);
|
||||
const context=channel.evidenceChannel.receive(body,item.bundle,item.privateKey);
|
||||
try{assert.deepEqual(context.payload,payload);const response=channel.evidenceChannel.response(result,item.bundle,context);return mutateResponse?mutateResponse(response):response;}finally{context.responseKey.fill(0);}
|
||||
};
|
||||
return {device,calls,request,save:actual=>{assert.equal(actual,device);assert.equal(actual.privateEnrollmentFlag,true);saved++;},saved:()=>saved};
|
||||
}
|
||||
test('browser evidence uses real PQ encryption and authenticated acknowledgement; legacy pin migration preserves enrollment state',async()=>{
|
||||
const f=fixture({legacy:true});assert.deepEqual(await browser.protectedEvidenceRequest({...f,payload}),{id:'synthetic-ack'});assert.equal(f.saved(),1);assert.equal(f.device.channelSignatureKey,signatureKey);
|
||||
assert.deepEqual(f.calls.map(c=>c.route),['/api/device/channel/identity','/api/device/channel/evidence/challenge','/api/device/channel/evidence']);
|
||||
await browser.protectedEvidenceRequest({...f,payload});assert.equal(f.saved(),1);
|
||||
});
|
||||
test('authenticated evidence rejection preserves status without disclosing server error content',async()=>{
|
||||
const f=fixture({result:{status:403,body:{error:'synthetic confidential server detail'}}});
|
||||
await assert.rejects(browser.protectedEvidenceRequest({...f,payload}),error=>error.status===403&&error.message==='Protected evidence submission refused');
|
||||
});
|
||||
test('tampered evidence response and unauthenticated status never become a trusted acknowledgement',async()=>{
|
||||
for(const mutateResponse of [r=>({...r,status:403}),r=>({...r,encrypted:{...r.encrypted,tag:Buffer.alloc(16).toString('base64url')}})]){
|
||||
const f=fixture({mutateResponse});await assert.rejects(browser.protectedEvidenceRequest({...f,payload}),error=>error.status===undefined);
|
||||
}
|
||||
for(const result of [{status:201,body:[]},{status:200,body:null},{status:201,body:{},extra:true},{status:199,body:{}}])await assert.rejects(browser.protectedEvidenceRequest({...fixture({result}),payload}));
|
||||
});
|
||||
test('wrong pin and cross-purpose challenge are refused before evidence transmission',async()=>{
|
||||
const wrong=fixture();wrong.device.channelSignatureKey=generateKeyPairSync('ml-dsa-87').publicKey.export({type:'spki',format:'pem'});
|
||||
await assert.rejects(browser.protectedEvidenceRequest({...wrong,payload}));assert.equal(wrong.calls.length,1);
|
||||
const cross=fixture({mutateChallenge:()=>channel.challenge('device-a',ed.privateKey,pq.privateKey).bundle});
|
||||
await assert.rejects(browser.protectedEvidenceRequest({...cross,payload}));assert.equal(cross.calls.length,1);
|
||||
});
|
||||
test('transport failure has no plaintext evidence fallback',async()=>{
|
||||
const f=fixture(),request=async(...args)=>{if(args[1]==='/api/device/channel/evidence')throw Error('Synthetic offline transport');return f.request(...args);};
|
||||
await assert.rejects(browser.protectedEvidenceRequest({...f,request,payload}));assert.equal(f.calls.some(c=>c.route==='/api/device/evidence'),false);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join} from 'node:path';import {execFileSync} from 'node:child_process';import {randomBytes,randomUUID} from 'node:crypto';
|
||||
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 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 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=>{
|
||||
const f=await fixture(t);assert.equal((await f.api('/api/device/evidence',f.payload)).status,403);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
let response=await f.api('/api/device/channel/evidence',a.context.request);assert.equal(response.status,200);assert.equal(channel.evidenceChannel.finish(response.body,a.bundle,a.context).status,201);assert.equal((await f.api('/api/device/channel/evidence',a.context.request)).status,403);a.context.responseKey.fill(0);
|
||||
a=await f.make();const altered=structuredClone(a.context.request);altered.encrypted.tag=Buffer.alloc(16).toString('base64url');assert.equal((await f.api('/api/device/channel/evidence',altered)).status,403);assert.equal((await f.api('/api/device/channel/evidence',a.context.request)).status,403);a.context.responseKey.fill(0);
|
||||
});
|
||||
test('central disable after issuance returns encrypted denial and revoked credentials cannot upload',async t=>{
|
||||
const f=await fixture(t),a=await f.make(),p=f.store.get('policy');p.evidence.enabled=false;f.store.put('policy',p);const response=await f.api('/api/device/channel/evidence',a.context.request);assert.equal(response.status,200);assert.equal(channel.evidenceChannel.finish(response.body,a.bundle,a.context).status,403);assert.equal(f.store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,0);assert.equal((await f.api('/api/device/channel/evidence/challenge',{})).status,403);a.context.responseKey.fill(0);
|
||||
p.evidence.enabled=true;f.store.put('policy',p);const b=await f.make();f.store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(f.device.id);assert.equal((await f.api('/api/device/channel/evidence',b.context.request)).status,401);b.context.responseKey.fill(0);
|
||||
});
|
||||
test('expired evidence challenge never decrypts or stores payload and remains one-use',async t=>{
|
||||
const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const f=await fixture(t),a=await f.make();t.mock.timers.setTime(now+60001);assert.equal((await f.api('/api/device/channel/evidence',a.context.request)).status,403);assert.equal((await f.api('/api/device/channel/evidence',a.context.request)).status,403);assert.equal(f.store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,0);a.context.responseKey.fill(0);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync,randomBytes} from 'node:crypto';
|
||||
import policy from '../packages/core/policy-channel.cjs';
|
||||
const channel=policy.evidenceChannel;
|
||||
const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87');
|
||||
const edPublic=ed.publicKey.export({type:'spki',format:'pem'}),pqPublic=pq.publicKey.export({type:'spki',format:'pem'});
|
||||
const make=()=>channel.challenge('synthetic-device',ed.privateKey,pq.privateKey);
|
||||
function exchange(){const challenge=make(),client=channel.prepare(challenge.bundle,{content:'synthetic confidential evidence',sourceEventId:'fixture-event'}),server=channel.receive(client.request,challenge.bundle,challenge.privateKey);return {...challenge,client,server};}
|
||||
test('real evidence KEM encrypts payload and authenticates signed identity and response',()=>{
|
||||
const identity=channel.identity(ed.privateKey,pq.privateKey,pqPublic);assert.equal(channel.verifyIdentity(identity,edPublic,pqPublic),pqPublic);
|
||||
const x=exchange();channel.verifyChallenge(x.bundle,'synthetic-device',edPublic,pqPublic);
|
||||
assert.equal(JSON.stringify(x.client.request).includes('synthetic confidential'),false);assert.equal(x.server.payload.content,'synthetic confidential evidence');
|
||||
const response=channel.response({accepted:true},x.bundle,x.server);assert.deepEqual(channel.finish(response,x.bundle,x.client),{accepted:true});
|
||||
assert.throws(()=>channel.finish({...response,encrypted:x.client.request.encrypted},x.bundle,x.client));
|
||||
x.client.responseKey.fill(0);x.server.responseKey.fill(0);
|
||||
});
|
||||
test('policy and evidence protocols cannot substitute identity, challenge or ciphertext',()=>{
|
||||
const x=exchange(),other=policy.challenge('synthetic-device',ed.privateKey,pq.privateKey);
|
||||
assert.throws(()=>policy.verifyIdentity(channel.identity(ed.privateKey,pq.privateKey,pqPublic),edPublic,pqPublic));
|
||||
assert.throws(()=>policy.verifyChallenge(x.bundle,'synthetic-device',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.verifyChallenge(other.bundle,'synthetic-device',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.prepare(other.bundle,{content:'fixture'}));
|
||||
assert.throws(()=>policy.receive(x.client.request,x.bundle,x.privateKey));
|
||||
const p=policy.prepare(other.bundle);assert.throws(()=>channel.receive(p.request,other.bundle,other.privateKey));
|
||||
p.responseKey.fill(0);x.client.responseKey.fill(0);x.server.responseKey.fill(0);
|
||||
});
|
||||
test('evidence refuses tampering, altered device, challenge, response key and request identity',()=>{
|
||||
const x=exchange(),other=make();
|
||||
assert.throws(()=>channel.verifyChallenge({...x.bundle,deviceId:'other'},'other',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.receive({...x.client.request,deviceId:'other'},x.bundle,x.privateKey));
|
||||
assert.throws(()=>channel.receive({...x.client.request,id:other.bundle.id},x.bundle,x.privateKey));
|
||||
const altered=structuredClone(x.client.request);altered.encrypted.tag=randomBytes(16).toString('base64url');assert.throws(()=>channel.receive(altered,x.bundle,x.privateKey));
|
||||
const response=channel.response({accepted:true},x.bundle,x.server);
|
||||
assert.throws(()=>channel.finish(response,x.bundle,{...x.client,responseKey:randomBytes(32)}));
|
||||
assert.throws(()=>channel.finish(response,x.bundle,{...x.client,requestId:'other'}));
|
||||
assert.throws(()=>channel.finish({...response,id:other.bundle.id},x.bundle,x.client));
|
||||
x.client.responseKey.fill(0);x.server.responseKey.fill(0);
|
||||
});
|
||||
test('evidence payload size and strict envelope are bounded; replay consumption belongs to server',()=>{
|
||||
const x=make();assert.throws(()=>channel.prepare(x.bundle,{content:'x'.repeat(65536)}));assert.throws(()=>channel.prepare(x.bundle,[]));
|
||||
const client=channel.prepare(x.bundle,{content:'x'.repeat(65500)}),first=channel.receive(client.request,x.bundle,x.privateKey);
|
||||
assert.equal(first.payload.content.length,65500);
|
||||
assert.throws(()=>channel.receive({...client.request,extra:true},x.bundle,x.privateKey));
|
||||
assert.throws(()=>channel.receive(client.request,{...x.bundle,expiresAt:Date.now()-1},x.privateKey));
|
||||
client.responseKey.fill(0);first.responseKey.fill(0);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {deliverAuditRecord,stripPendingEvidence,collectionAllowed,validatedEvidence}=createRequire(import.meta.url)('../apps/browser/evidence-delivery.cjs');
|
||||
function fixture(){
|
||||
const payload={sourceUrl:'https://example.com/report',name:'report.pdf',sha256:'a'.repeat(64),bytes:12,mime:'application/pdf',decision:'allow',ruleId:'scanner-allow'};
|
||||
const record={eventId:'b7a17c1d-680e-4c90-9a3c-3d18f50a4e3f',kind:'download.released',file:{sha256:payload.sha256,bytes:payload.bytes},protectedEvidence:{deviceId:'device-a',payload}};
|
||||
const context={ready:true,deviceId:'device-a',policy:{expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true}}};
|
||||
return {payload,record,context};
|
||||
}
|
||||
test('event-first retries preserve original UUID and atomic tuple without leaking evidence into events',async()=>{
|
||||
const f=fixture(),calls=[],snapshot=structuredClone(f.record);let failure=true;
|
||||
const send=async(route,body)=>{calls.push({route,body});if(route.endsWith('/evidence')){if(failure){const error=Error('temporarily unavailable');error.status=503;throw error;}return {id:'00000000-0000-4000-8000-000000000001',sourceEventId:7};}return {};};
|
||||
await assert.rejects(deliverAuditRecord({record:f.record,send,getContext:()=>f.context}),/temporarily unavailable/);assert.deepEqual(f.record,snapshot);
|
||||
failure=false;assert.equal((await deliverAuditRecord({record:f.record,send,getContext:()=>f.context})).evidenceDelivered,true);
|
||||
assert.deepEqual(calls.map(c=>c.route),['/api/device/events','/api/device/evidence','/api/device/events','/api/device/evidence']);
|
||||
for(const call of calls){assert.equal(call.body.eventId,f.record.eventId);if(call.route.endsWith('/events'))assert.equal('protectedEvidence' in call.body,false);else assert.deepEqual(call.body,{eventId:f.record.eventId,type:'download',payload:f.payload});}
|
||||
});
|
||||
test('403 discards evidence after source event; unavailable evidence service keeps record retryable',async()=>{
|
||||
for(const status of [403,503]){const f=fixture(),routes=[];const task=deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{routes.push(route);if(route.endsWith('/evidence'))throw Object.assign(Error('rejected'),{status});return {};}});
|
||||
if(status===403)assert.deepEqual(await task,{eventDelivered:true,evidenceDiscarded:true});else await assert.rejects(task,/rejected/);assert.deepEqual(routes,['/api/device/events','/api/device/evidence']);assert.ok(f.record.protectedEvidence);}
|
||||
});
|
||||
test('device change, central disable and expired policy discard only evidence while preserving event',async()=>{
|
||||
for(const change of [f=>f.context.deviceId='device-b',f=>f.context.policy.evidence.enabled=false,f=>f.context.policy.evidence.captureDownloads=false,f=>f.context.policy.expiresAt='2000-01-01T00:00:00Z']){const f=fixture(),routes=[];change(f);assert.deepEqual(await deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{routes.push(route);return {};}}),{eventDelivered:true,evidenceDiscarded:true});assert.deepEqual(routes,['/api/device/events']);}
|
||||
const f=fixture(),stripped=stripPendingEvidence([f.record]);assert.equal(stripped[0].eventId,f.record.eventId);assert.deepEqual(stripped[0].file,f.record.file);assert.equal('protectedEvidence' in stripped[0],false);assert.ok(f.record.protectedEvidence);
|
||||
});
|
||||
test('not-ready managed context pauses evidence after source event without losing tuple',async()=>{
|
||||
const f=fixture(),routes=[];f.context.ready=false;await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{routes.push(route);return {};}}),/paused/);assert.deepEqual(routes,['/api/device/events']);assert.ok(f.record.protectedEvidence);
|
||||
});
|
||||
test('invalid evidence acknowledgement and invalid payload never mark evidence delivered',async()=>{
|
||||
for(const acknowledgement of [undefined,{}, {id:7,sourceEventId:1},{id:'id',sourceEventId:1.5},{id:'00000000-0000-4000-8000-000000000001',sourceEventId:0},{id:'',sourceEventId:1}]){const f=fixture();await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>route.endsWith('/evidence')?acknowledgement:{}}),/acknowledgement invalid/);}
|
||||
const f=fixture(),routes=[];f.record.protectedEvidence.payload.secret='forbidden';await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{routes.push(route);return {};}}),/Invalid protected/);assert.deepEqual(routes,['/api/device/events']);
|
||||
});
|
||||
test('collection requires enabled current ready context and validation returns independent clone',()=>{
|
||||
const f=fixture();assert.equal(collectionAllowed(f.context),true);assert.equal(collectionAllowed({...f.context,ready:false}),false);assert.equal(collectionAllowed({...f.context,policy:{...f.context.policy,expiresAt:'invalid'}}),false);const clone=validatedEvidence(f.payload);clone.name='changed';assert.equal(f.payload.name,'report.pdf');
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';import {execFileSync} from 'node:child_process';import {randomBytes} from 'node:crypto';import {assertPrivateEvidenceKey} from '../apps/server/evidence-key.mjs';import {normalizePrincipal,principalForGroups} from '../apps/server/admin-access.mjs';
|
||||
test('analyst identity grants evidence/triage but no policy or fleet administration',()=>{const p=normalizePrincipal({sub:'soc',role:'analyst'});assert.ok(p.permissions.includes('evidence.read'));for(const permission of ['policy.write','devices.manage','sessions.manage','integrations.manage'])assert.equal(p.permissions.includes(permission),false);assert.equal(principalForGroups({sub:'soc'},['soc-team'],{oidcAdminGroup:'admins',oidcAnalystGroup:'soc-team'}).role,'analyst');});
|
||||
test('Windows evidence key rejects inherited shared read permissions',{skip:process.platform!=='win32'},t=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-acl-')),file=join(dir,'evidence.key');try{writeFileSync(file,randomBytes(32));let rejected=false;try{assertPrivateEvidenceKey(file);}catch{rejected=true;}if(!rejected){t.skip('This Windows temp directory already supplies private inherited ACLs');return;}assert.equal(rejected,true);}finally{if(!resolve(dir).startsWith(resolve(tmpdir())+sep))throw Error('Unsafe cleanup');rmSync(dir,{recursive:true,force:true});}});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
function fixture({evidence,review=true}={}){
|
||||
const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{checked:false,disabled:false,addEventListener(kind,fn){this[kind]=fn;}});return fields.get(id);};
|
||||
let changes=0;
|
||||
const context=vm.createContext({$:get,policy:{evidence},currentPrincipal:null,independentApprovalRequired:review,fill(){},draft:()=>({version:4,brand:{name:'Company'}}),applyOperatorPermissions(){},updateDraftStatus(){changes++;}});
|
||||
vm.runInContext(readFileSync(new URL('../apps/server/public/evidence-policy-admin.js',import.meta.url),'utf8'),context);
|
||||
return {context,get,changes:()=>changes};
|
||||
}
|
||||
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);
|
||||
});
|
||||
test('independent preparation permission enables draft controls; global off disables child control',()=>{
|
||||
const {context,get,changes}=fixture({evidence:{enabled:true,captureDownloads:true}});
|
||||
context.applyOperatorPermissions({permissions:['policy.read']});assert.equal(get('evidence-policy-enabled').disabled,true);
|
||||
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);
|
||||
});
|
||||
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);
|
||||
context.applyOperatorPermissions({permissions:['policy.write']});assert.equal(get('evidence-policy-enabled').disabled,false);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {defaultPolicy,PolicySchema,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
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 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=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-policy-')),store=openStore(dir),adminToken='synthetic-evidence-policy-admin-123456789';const server=createApp(store,{publicUrl:'http://127.0.0.1',secure:false,adminToken}).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-evidence-policy-')));rmSync(dir,{recursive:true,force:true});});
|
||||
const p=store.get('policy');p.version++;p.evidence={enabled:true,captureDownloads:true};const response=await fetch(`http://127.0.0.1:${server.address().port}/api/admin/policy`,{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(p)});assert.equal(response.status,400);assert.equal(store.get('policy').evidence.enabled,false);assert.equal(store.get('policy').version,1);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createEvidenceViewer}=createRequire(import.meta.url)('../apps/server/public/evidence-viewer.js');
|
||||
function fixture(){let principal={sub:'analyst',role:'analyst',permissions:['evidence.read']},timer;const requests=[],renders=[];const view=createEvidenceViewer({principal:()=>principal,request:path=>new Promise((resolve,reject)=>requests.push({path,resolve,reject})),render:(...args)=>renders.push(args),clear:()=>renders.push(['clear']),setTimer:(fn,ms)=>{assert.equal(ms,60000);timer=fn;return 1;},cancelTimer:()=>{timer=null;}});return {view,requests,renders,revoke:()=>{principal={...principal,permissions:[]};view.permissions();},identity:()=>{principal={...principal,sub:'other'};view.permissions();},expire:()=>timer()};}
|
||||
const id='11111111-1111-4111-8111-111111111111';
|
||||
async function open(f,incident='one'){const result=f.view.open(incident);f.requests.at(-1).resolve({evidence:[{id,availability:'available'}]});await result;}
|
||||
test('metadata alone does not retrieve plaintext; purpose required',async()=>{const f=fixture();await open(f);assert.equal(f.requests.length,1);await f.view.reveal(id,'');assert.equal(f.requests.length,1);const reveal=f.view.reveal(id,'investigation');assert.match(f.requests.at(-1).path,/reason=investigation$/);f.requests.at(-1).resolve({id,type:'interaction',payload:{content:'<script>synthetic</script>'}});await reveal;assert.equal(f.renders.at(-1)[0],'plaintext');assert.match(f.renders.at(-1)[1],/<script>/);f.expire();assert.deepEqual(f.renders.at(-1),['clear']);});
|
||||
for(const action of ['reset','revoke','identity'])test(`in-flight plaintext cannot repopulate after ${action}`,async()=>{const f=fixture();await open(f);const reveal=f.view.reveal(id,'incident-response');if(action==='reset')f.view.reset();else f[action]();f.requests.at(-1).resolve({id,type:'download',payload:{name:'synthetic'}});await reveal;assert.deepEqual(f.renders.at(-1),['clear']);});
|
||||
test('older incident metadata cannot replace new selection',async()=>{const f=fixture(),old=f.view.open('old'),newer=f.view.open('new');f.requests[1].resolve({evidence:[{id}]});await newer;const expected=f.renders.length;f.requests[0].resolve({evidence:[{id:'old'}]});await old;assert.equal(f.renders.length,expected);});
|
||||
test('errors are sanitized and payloads bounded',async()=>{const f=fixture();await open(f);let reveal=f.view.reveal(id,'compliance');f.requests.at(-1).reject(Error('SECRET backend detail'));await reveal;assert.doesNotMatch(f.renders.at(-1)[1],/SECRET/);reveal=f.view.reveal(id,'compliance');f.requests.at(-1).resolve({id,type:'interaction',payload:{content:'x'.repeat(70000)}});await reveal;assert.equal(f.renders.at(-1)[0],'error');});
|
||||
|
||||
test('viewer cannot fetch evidence outside the displayed incident',async()=>{const f=fixture();await open(f);const count=f.requests.length;await f.view.reveal('22222222-2222-4222-8222-222222222222','investigation');assert.equal(f.requests.length,count);assert.equal(f.renders.at(-1)[0],'error');});
|
||||
test('viewer suppresses a mismatched source event in a decrypted response',async()=>{const f=fixture(),opening=f.view.open('one');f.requests.at(-1).resolve({evidence:[{id,availability:'available',sourceEventId:42}]});await opening;const revealing=f.view.reveal(id,'investigation');f.requests.at(-1).resolve({id,sourceEventId:43,type:'interaction',payload:{content:'must not render'}});await revealing;assert.equal(f.renders.at(-1)[0],'error');assert.equal(f.renders.some(([kind,value])=>kind==='plaintext'&&value.includes('must not render')),false);});
|
||||
+53
-4
@@ -1,6 +1,44 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';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';
|
||||
async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32));const store=openStore(dir),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};}
|
||||
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';
|
||||
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}});
|
||||
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);
|
||||
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,contentSha256:'0'.repeat(64)}})).status,400);
|
||||
const download=randomUUID();store.event(id,'download.released',{eventId:download,host:'downloads.example',file:{sha256:'a'.repeat(64),bytes:10,detectedType:'text'}});
|
||||
const payload={sourceUrl:'https://downloads.example/file.txt',name:'file.txt',sha256:'a'.repeat(64),bytes:10,mime:'text/plain',decision:'allow'};
|
||||
for(const change of [{sha256:'b'.repeat(64)},{bytes:11},{decision:'block'},{sourceUrl:'https://other.example/file.txt'}])assert.equal((await api('/api/device/evidence','POST',{eventId:download,type:'download',payload:{...payload,...change}})).status,400);
|
||||
assert.equal((await api('/api/device/evidence','POST',{eventId:download,type:'download',payload})).status,201);
|
||||
});
|
||||
test('incident discovery lists only linked metadata without decrypting or releasing content',async t=>{
|
||||
const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));
|
||||
const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');
|
||||
const path=`/api/admin/incidents/${incident.body.id}/evidence`;
|
||||
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 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);
|
||||
// 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);
|
||||
assert.equal((await api('/api/admin/incidents/not-a-uuid/evidence','GET',undefined,'admin')).status,400);assert.equal((await api(`/api/admin/incidents/${randomUUID()}/evidence`,'GET',undefined,'admin')).status,404);
|
||||
});
|
||||
test('incident discovery reports expiry, active hold and unavailable keys without exposing key identifiers',async t=>{
|
||||
const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));
|
||||
const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin'),path=`/api/admin/incidents/${incident.body.id}/evidence`;
|
||||
t.mock.timers.setTime(now+31*86400000);assert.equal((await api(path,'GET',undefined,'admin')).body.evidence[0].availability,'available');
|
||||
await api(`/api/admin/incidents/${incident.body.id}`,'PATCH',{expectedRevision:1,status:'resolved'},'admin');assert.equal((await api(path,'GET',undefined,'admin')).body.evidence[0].availability,'expired');
|
||||
t.mock.timers.setTime(now);store.db.prepare('UPDATE protected_evidence SET key_id=?').run('synthetic-other-key-id');const result=await api(path,'GET',undefined,'admin');assert.equal(result.body.evidence[0].availability,'key-unavailable');assert.equal(JSON.stringify(result.body).includes('synthetic-other-key-id'),false);
|
||||
});
|
||||
test('recognizable credentials in free text, target labels, filenames and encoded paths never persist',async t=>{
|
||||
const {api,store,eventId}=await fixture(t);
|
||||
const values=['-----BEGIN PRIVATE KEY-----\nsynthetic','Bearer syntheticAuthorizationCredential123','Basic c3ludGhldGljOnBhc3N3b3Jk','password="synthetic-secret"','passwd=synthetic-secret','api_key: synthetic-secret','access-token=synthetic-secret','refresh_token=synthetic-secret','client_secret=synthetic-secret','sk-syntheticCredential1234567890','ghp_syntheticCredential1234567890','AKIAABCDEFGHIJKLMNOP','eyJsyntheticHeader.syntheticPayload.syntheticSignature'];
|
||||
@@ -11,11 +49,22 @@ 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,409);});
|
||||
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('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;});
|
||||
test('retention preserves encrypted evidence source events',async t=>{const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));store.db.prepare('UPDATE events SET time=?').run('2020-01-01T00:00:00.000Z');const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');pruneAuditEvents(store,{days:1,exportedThrough:1000,dryRun:false});assert.ok(store.db.prepare("SELECT id FROM events WHERE kind='clipboard.copy'").get());});
|
||||
test('retention preserves encrypted evidence source events',async t=>{const {api,store,eventId}=await fixture(t);await api('/api/device/evidence','POST',evidence(eventId));const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');pruneAuditEvents(store,{now:Date.now()+60*86400000,days:1,exportedThrough:1000,dryRun:false});assert.ok(store.db.prepare("SELECT id FROM events WHERE kind='clipboard.copy'").get());});
|
||||
test('download facts roundtrip while a different enrolled device cannot attach evidence',async t=>{const {api,store,id}=await fixture(t),eventId=randomUUID();store.event(id,'download.released',{eventId});const payload={sourceUrl:'https://downloads.example/package.exe',name:'package.exe',sha256:'a'.repeat(64),bytes:1024,mime:'application/octet-stream',decision:'allow'};const result=await api('/api/device/evidence','POST',{eventId,type:'download',payload});assert.equal(result.status,201);const detail=await api(`/api/admin/evidence/${result.body.id}?reason=compliance`,'GET',undefined,'admin');assert.deepEqual(detail.body.payload,payload);const other=randomUUID();store.db.prepare('INSERT INTO devices(id,revoked) VALUES(?,0)').run(other);const otherEvent=randomUUID();store.event(other,'clipboard.copy',{eventId:otherEvent});assert.equal((await api('/api/device/evidence','POST',evidence(otherEvent))).status,404);});
|
||||
test('expired access is denied; incident legal hold does not bypass ciphertext integrity',async t=>{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('2000-01-01T00:00:00.000Z');assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,500);});
|
||||
test('genuine expiry preserves readability only while the linked incident is active',async t=>{const now=Date.now();t.mock.timers.enable({apis:['Date'],now});const {api,store,eventId}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));t.mock.timers.setTime(now+31*86400000);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);const source=store.db.prepare('SELECT source_event_id FROM protected_evidence').get().source_event_id,incident=await api('/api/admin/incidents','POST',{eventId:source},'admin');assert.equal(incident.status,201);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/incidents/${incident.body.id}`,'PATCH',{expectedRevision:1,status:'resolved'},'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,410);});
|
||||
test('centrally disabled and expired evidence policies reject new capture while retaining prior access',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 p=store.get('policy');p.evidence.enabled=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
|
||||
assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst')).status,200);
|
||||
p.evidence.enabled=true;p.expiresAt=new Date(Date.now()-1000).toISOString();store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
|
||||
});
|
||||
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(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);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createFileUploadGate}=createRequire(import.meta.url)('../apps/browser/file-upload-gate.cjs');
|
||||
function fixture(inspectPart=async()=>({decision:'allow',coverage:{complete:true}})){
|
||||
let current=true,alive=true,releases=0,acquires=0,lost;
|
||||
const aborted=[];const context={};
|
||||
const gate=createFileUploadGate({maxBytes:()=>100,isCurrent:()=>current,abort:id=>aborted.push(id),inspectPart,
|
||||
createLease:async({paths,onLost})=>{acquires++;lost=onLost;return {files:paths.map(path=>({path,bytes:Buffer.from('file')})),isAlive:()=>alive,release:async()=>{releases++;}};}});
|
||||
const details={id:1,url:'https://example.test/upload',uploadData:[{type:'rawData',bytes:Buffer.from('prefix')},{type:'file',file:'C:/synthetic',offset:0,length:-1},{type:'rawData',bytes:Buffer.from('suffix')}]};
|
||||
return {gate,context,details,aborted,get releases(){return releases;},get acquires(){return acquires;},change(){current=false;},lose(){alive=false;lost();}};
|
||||
}
|
||||
test('approved full-file multipart holds leases through terminal and returns hash-free facts',async()=>{
|
||||
const f=fixture();const result=await f.gate.inspect(f.details,f.context);assert.equal(result.decision,'allow');assert.deepEqual(result.request,{bytes:16,detectedType:'unknown'});assert.equal(f.releases,0);
|
||||
assert.deepEqual(await f.gate.inspect(f.details,f.context),result);assert.equal(f.acquires,1);await f.gate.terminal(1);assert.equal(f.releases,1);
|
||||
});
|
||||
test('denied scan aborts but retains lock until native terminal',async()=>{
|
||||
const f=fixture(async()=>({decision:'block',ruleId:'secret'}));assert.equal((await f.gate.inspect(f.details,f.context)).ruleId,'secret');assert.deepEqual(f.aborted,[1]);assert.equal(f.releases,0);await f.gate.terminal(1);assert.equal(f.releases,1);
|
||||
});
|
||||
test('ranges and data-pipe shapes fail before lease acquisition',async()=>{
|
||||
for(const part of [{type:'file',file:'C:/synthetic',offset:1,length:-1},{type:'blob',blobUUID:'x'},{type:'rawData',bytes:Buffer.from('x'),file:'C:/x'}]){
|
||||
const f=fixture();f.details.uploadData=[part];assert.equal((await f.gate.inspect(f.details,f.context)).decision,'block');assert.equal(f.acquires,0);await f.gate.terminal(1);
|
||||
}
|
||||
});
|
||||
test('joined raw segments detect boundary split and never produce generic request hash',async()=>{
|
||||
const f=fixture(async bytes=>bytes.toString()==='prefixsuffix'?{decision:'block',ruleId:'joined'}:{decision:'allow',coverage:{complete:true}});
|
||||
assert.equal((await f.gate.inspect(f.details,f.context)).ruleId,'joined');await f.gate.terminal(1);
|
||||
});
|
||||
test('policy change during scan and helper loss retain lease until terminal',async()=>{
|
||||
let f;f=fixture(async()=>{f.change();return {decision:'allow',coverage:{complete:true}};});assert.equal((await f.gate.inspect(f.details,f.context)).decision,'block');assert.equal(f.releases,0);await f.gate.terminal(1);
|
||||
const g=fixture();await g.gate.inspect(g.details,g.context);g.lose();assert.deepEqual(g.aborted,[1]);assert.equal(g.releases,0);await g.gate.terminal(1);
|
||||
});
|
||||
test('missing complete coverage denies and concurrency includes denied pending leases',async()=>{
|
||||
const f=fixture(async()=>({decision:'allow'}));for(let id=1;id<=8;id++)assert.equal((await f.gate.inspect({...f.details,id},f.context)).decision,'block');assert.equal((await f.gate.inspect({...f.details,id:9},f.context)).ruleId,'upload-capacity');f.gate.dispose();assert.equal(f.releases,0);for(let id=1;id<=8;id++)await f.gate.terminal(id);assert.equal(f.releases,8);
|
||||
});
|
||||
test('same native id cannot reuse approval for a changed body or method',async()=>{
|
||||
const f=fixture();await f.gate.inspect(f.details,f.context);f.details.uploadData[0].bytes[0]=88;
|
||||
assert.equal((await f.gate.inspect(f.details,f.context)).ruleId,'request-changed');assert.equal(f.releases,0);await f.gate.terminal(1);
|
||||
const g=fixture();await g.gate.inspect(g.details,g.context);assert.equal((await g.gate.inspect({...g.details,method:'PUT'},g.context)).ruleId,'request-changed');await g.gate.terminal(1);
|
||||
});
|
||||
test('raw snapshots precede asynchronous acquisition and file scan receives exact path',async()=>{
|
||||
const raw=Buffer.from('original'),seen=[];let released=0;
|
||||
const gate=createFileUploadGate({maxBytes:()=>100,isCurrent:()=>true,abort:()=>{},
|
||||
createLease:async({paths})=>{raw.fill(88);return {files:[{path:paths[0],bytes:Buffer.from('file')}],isAlive:()=>true,release:async()=>{released++;}};},
|
||||
inspectPart:async(bytes,options)=>{seen.push({text:bytes.toString(),filename:options.filename});return {decision:'allow',coverage:{complete:true}};}});
|
||||
assert.equal((await gate.inspect({id:1,url:'https://example.test',method:'POST',uploadData:[{type:'rawData',bytes:raw},{type:'file',file:'C:/specific.xls',offset:0,length:-1}]},{})).decision,'allow');
|
||||
assert.equal(seen[0].text,'original');assert.equal(seen[1].filename,'C:/specific.xls');assert.equal(seen[2].text,'original');await gate.terminal(1);assert.equal(released,1);
|
||||
});
|
||||
test('terminal lease cleanup failures propagate without pretending success',async()=>{
|
||||
const gate=createFileUploadGate({maxBytes:()=>100,isCurrent:()=>true,abort:()=>{},createLease:async({paths})=>({files:[{path:paths[0],bytes:Buffer.from('file')}],isAlive:()=>true,release:async()=>{throw Error('cleanup-failed');}}),inspectPart:async()=>({decision:'allow',coverage:{complete:true}})});
|
||||
await gate.inspect({id:1,url:'https://example.test',uploadData:[{type:'file',file:'C:/specific.xls',offset:0,length:-1}]},{});await assert.rejects(gate.terminal(1),/cleanup-failed/);
|
||||
});
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2013-2025, John McNamara <[email protected]>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
Benign say_hello VBA fixture from XlsxWriter examples/vbaProject.bin, revision 5d4606d89a955226d2d0825a0f44309043ae7251. SHA-256: 0ced1464b3677e98f5e3a8c5d80135e18dc98dca39299f1a8cfd2a00999fbf9f. Base64 stores binary data transparently; tests never execute Office or macros. Original BSD-2-Clause license included.
|
||||
@@ -0,0 +1 @@
|
||||
0M8R4KGxGuEAAAAAAAAAAAAAAAAAAAAAPgADAP7/CQAGAAAAAAAAAAAAAAABAAAAAQAAAAAAAAAAEAAAAgAAAAIAAAD+////AAAAAAAAAAD////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9////BwAAABQAAAAEAAAABQAAAAYAAAAIAAAADQAAAAkAAAAKAAAACwAAAAwAAAAOAAAAGwAAAA8AAAAQAAAAEQAAABIAAAATAAAAFQAAAP7///8WAAAAFwAAABgAAAAZAAAAGgAAABwAAAD+////HQAAAP7//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////1IAbwBvAHQAIABFAG4AdAByAHkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAAUA//////////8OAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALCnnEHE988BAwAAAMAsAAAAAAAAVgBCAEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAQD//////////wQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALCnnEHE988BsKecQcT3zwEAAAAAAAAAAAAAAABUAGgAaQBzAFcAbwByAGsAYgBvAG8AawAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgACAP///////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADhAwAAAAAAAFMAaABlAGUAdAAxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAIBCgAAAAgAAAD/////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAANoDAAAAAAAAAQAAAAIAAAADAAAABAAAAAUAAAAGAAAABwAAAAgAAAAJAAAACgAAAAsAAAAMAAAADQAAAA4AAAAPAAAA/v///xEAAAASAAAAEwAAABQAAAAVAAAAFgAAABcAAAAYAAAAGQAAABoAAAAbAAAAHAAAAB0AAAAeAAAAHwAAAP7///8hAAAAIgAAACMAAAAkAAAAJQAAACYAAAAnAAAAKAAAACkAAAAqAAAAKwAAACwAAAAtAAAALgAAAC8AAAAwAAAA/v///zIAAAAzAAAANAAAAP7///82AAAA/v///zgAAAA5AAAAOgAAADsAAAA8AAAAPQAAAD4AAAA/AAAAQAAAAEEAAABCAAAAQwAAAEQAAABFAAAARgAAAP7///9IAAAASQAAAEoAAABLAAAATAAAAE0AAABOAAAATwAAAFAAAABRAAAAUgAAAFMAAABUAAAAVQAAAFYAAAD+////WAAAAFkAAABaAAAAWwAAAFwAAABdAAAAXgAAAF8AAABgAAAAYQAAAGIAAABjAAAAZAAAAGUAAABmAAAAZwAAAGgAAABpAAAAagAAAGsAAABsAAAAbQAAAG4AAABvAAAAcAAAAHEAAAByAAAAcwAAAHQAAAB1AAAAdgAAAHcAAAB4AAAAeQAAAHoAAAB7AAAAfAAAAH0AAAB+AAAAfwAAAIAAAAABFgEAAfAAAADMAgAA1AAAAAACAAD/////0wIAACcDAAAAAAAAAQAAANqdJiAAAP//IwEAAIgAAAC2AP//AQEAAAAA/////wAAAAD///////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAwAAAAUAAAAHAAAA//////////8BAQgAAAD/////eAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAE1FAAD///////8AAAAA//8AAAAA//8BAQAAAADfAP//AAAAAAwA//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////8oAAAAAgBTTP////8AAAEAUxD/////AAABAFOU/////wAAAAACPP////8AAP//AQEAAAAAAQBOADAAewAwADAAMAAyADAAOAAxADkALQAwADAAMAAwAC0AMAAwADAAMAAtAEMAMAAwADAALQAwADAAMAAwADAAMAAwADAAMAAwADQANgB9AAYAAAD/////AQFAAAAAAoD+//////8QAP//KAAAAAIB//8AAAAAAAAAAP//////////AAAAAB0AAAAlAAAA/////0gAAAD/////QAAAAAAAAAAAAAEAAAAAAAAAAAD///////////////8AAAAA//////////////////////////8AAAAA//////////////////////////8AAAAAAQAAAP//////////AAAAAP///////////////////////////////wEAMAAAAFEfZ1IgAN8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+ygEAAAD/////AQEIAAAA/////3gAAAD/////AAABsLAAQXR0cmlidXQAZSBWQl9OYW0AZSA9ICJUaGkAc1dvcmtib28QayINCgqMQmFzAQKMMHswMDAyMFA4MTktABAwAwhDBwAUAhIBJDAwNDZ9gQ18R2xvYmFsAdAQU3BhYwGSRmFsBHNlDGRDcmVhdAhhYmwVH1ByZWSQZWNsYQAGSWQAsQhUcnUNQkV4cG8Ec2UUHFRlbXBsAGF0ZURlcml2AwISkkJ1c3RvbWkGegREAzIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARYBAAHwAAAAzAIAANQAAAAAAgAA/////9MCAAAnAwAAAAAAAAEAAADanYU6AAD//yMBAACIAAAAtgD//wEBAAAAAP////8AAAAA////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAMAAAAFAAAABwAAAP//////////AQEIAAAA/////3gAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//wAAAABNRQAA////////AAAAAP//AAAAAP//AQEAAAAA3wD//wAAAAAMAP//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////KAAAAAIAU0z/////AAABAFMQ/////wAAAQBTlP////8AAAAAAjz/////AAD//wEBAAAAAAEATgAwAHsAMAAwADAAMgAwADgAMQA5AC0AMAAwADAAMAAtADAAMAAwADAALQBDADAAMAAwAC0AMAAwADAAMAAwADAAMAAwADAAMAA0ADYAfQAGAAAA/////wEBQAAAAAKA/v//////EAD//ygAAAACAf//AAAAAAAAAAD//////////wAAAAAdAAAAJQAAAP////9IAAAA/////0AAAAAAAAAAAAABAAAAAAAAAAAA////////////////AAAAAP//////////////////////////AAAAAP//////////////////////////AAAAAAEAAAD////Line truncated
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
const hook=source.slice(source.indexOf(" browserSession.webRequest.onBeforeSendHeaders("),source.indexOf(' // Session request, header and permission gates'));
|
||||
function fixture({auditFails=false}={}){
|
||||
let listener,completeScan;
|
||||
const timers=[],callbacks=[],events=[];
|
||||
const policy={expiresAt:new Date(Date.now()+60000).toISOString(),dlp:{maxBytes:65536,semantic:{enabled:false}},security:{yara:{enabled:false}}};
|
||||
const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),currentTabContext:()=>true,URL,Date,ready:true,policy,localInspector:null,
|
||||
browserSession:{webRequest:{onBeforeSendHeaders(_filter,fn){listener=fn;}}},
|
||||
developerAssetRequestAllowed:()=>false,tabs:{all:()=>[]},core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})},
|
||||
diagnosticSpan:(_name,_attrs,fn)=>fn(),audit:async(...args)=>{events.push(args);if(auditFails)throw Error('fixture persistence failure');},
|
||||
scanEnterprise:()=>new Promise(resolve=>{completeScan=resolve;}),setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){},
|
||||
});
|
||||
vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context);
|
||||
vm.runInContext(hook,context);
|
||||
listener({url:'https://example.test/path',requestHeaders:{X:'public'},webContentsId:0,resourceType:'xhr'},response=>callbacks.push(response));
|
||||
return {timers,callbacks,events,context,finish:()=>completeScan({decision:'allow',coverage:{complete:true}})};
|
||||
}
|
||||
const settle=async()=>{for(let n=0;n<8;n++)await Promise.resolve();};
|
||||
test('native header deadline cancels once, records static audit reason and refuses late scanner approval',async()=>{
|
||||
const f=fixture();await settle();assert.equal(f.callbacks.length,0);
|
||||
f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true);
|
||||
assert.deepEqual(f.events,[['dlp.block','headers-inspection-timeout','example.test']]);
|
||||
f.timers[0]();f.finish();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.events.length,1);
|
||||
});
|
||||
test('audit failure cannot release a timed-out request or create unhandled rejection',async()=>{
|
||||
const f=fixture({auditFails:true});await settle();f.timers[0]();await settle();f.finish();await settle();
|
||||
assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true);assert.equal(f.events.length,1);
|
||||
});
|
||||
test('completed header approval is not later relabelled as a timeout',async()=>{
|
||||
const f=fixture();await settle();f.finish();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false);
|
||||
f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.events.length,0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {defaultPolicy,inspect} from '../packages/core/policy.mjs';
|
||||
const {snapshotHeaders,inspectHeaders}=createRequire(import.meta.url)('../apps/browser/header-inspection.cjs');
|
||||
function policy(){const p=defaultPolicy();p.dlp.rules=[{id:'roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];return p;}
|
||||
async function check(headers,p=policy(),overrides={}){return inspectHeaders(snapshotHeaders(headers,p),{policy:p,inspect,isCurrent:()=>true,...overrides});}
|
||||
test('custom, credential and browser metadata header values obey the same central DLP',async()=>{
|
||||
for(const name of ['X-Data','Cookie','Authorization','Proxy-Authorization','Referer','User-Agent'])assert.equal((await check({[name]:'Secret Roadmap'})).ruleId,'roadmap');
|
||||
assert.equal((await check({'X-Data':'Secret%20Roadmap'})).ruleId,'roadmap');
|
||||
assert.equal((await check({'X-First':'Secret ','X-Second':'Roadmap'})).ruleId,'roadmap');
|
||||
assert.equal((await check({'Secret-Roadmap':'public'})).decision,'allow');
|
||||
assert.equal((await check({Authorization:'Bearer sk-123456789012345678901234'})).ruleId,'api-secret');
|
||||
assert.equal((await check({Authorization:'Bearer eyJhbGciOiJIUzI1NiJ9.opaque.signature',Cookie:'session=ordinaryOpaqueValue'})).decision,'allow');
|
||||
});
|
||||
test('per-header exact hashes and registered content phrases remain effective',async()=>{
|
||||
const p=policy();p.dlp.rules=[{id:'reference',label:'Reference',kind:'sha256',value:createHash('sha256').update('known exact reference').digest('hex'),action:'block'}];
|
||||
assert.equal((await check({'X-Data':'known exact reference'},p)).ruleId,'reference');
|
||||
p.security.content.blockedPhrases=['Internal planning'];
|
||||
assert.equal((await check({Cookie:'x=Internal+planning'},p)).ruleId,'content-blocklist');
|
||||
});
|
||||
test('snapshot rejects malformed, inherited, accessor and oversized input without executing getters',()=>{
|
||||
const p=policy();let getterCalled=false;const input={};Object.defineProperty(input,'X',{enumerable:true,get(){getterCalled=true;return 'public';}});
|
||||
assert.throws(()=>snapshotHeaders(input,p),/uninspectable/);assert.equal(getterCalled,false);
|
||||
for(const malformed of [null,[],Object.create({X:'secret'}),{'Bad Header':'public'},{X:['public']},{X:'bad\r\nheader'},{X:'bad\0header'}])assert.throws(()=>snapshotHeaders(malformed,p),/uninspectable/);
|
||||
assert.throws(()=>snapshotHeaders(Object.fromEntries(Array.from({length:129},(_,i)=>[`X-${i}`,'ok'])),p),/size-limit/);
|
||||
assert.throws(()=>snapshotHeaders({X:'a'.repeat(65536)},p),/size-limit/);
|
||||
p.dlp.maxBytes=1024;assert.throws(()=>snapshotHeaders({X:'a'.repeat(1024)},p),/size-limit/);
|
||||
});
|
||||
test('approved snapshot cannot be changed by input mutations or scanners; buffers wiped',async()=>{
|
||||
const p=policy(),input={Cookie:'ordinary=session',X:'public'},snapshot=snapshotHeaders(input,p);input.X='Secret Roadmap';
|
||||
let held;const result=await inspectHeaders(snapshot,{policy:p,inspect,isCurrent:()=>true,inspectEnterprise:async bytes=>{held=bytes;return {decision:'allow',coverage:{complete:true}};}});
|
||||
assert.equal(result.decision,'allow');assert.equal(snapshot.headers.X,'public');assert.throws(()=>{snapshot.headers.X='Secret Roadmap';},TypeError);assert.ok(held.every(byte=>byte===0));
|
||||
assert.deepEqual(Object.keys(result).sort(),['decision','ruleId']);
|
||||
});
|
||||
test('local and enterprise inspection require complete allow and current policy',async()=>{
|
||||
const p=policy();p.dlp.semantic.enabled=true;
|
||||
assert.equal((await check({X:'public'},p)).ruleId,'inspector-unavailable');
|
||||
assert.equal((await check({X:'public'},p,{inspectLocal:async()=>({decision:'allow'})})).ruleId,'inspection-incomplete');
|
||||
let enterpriseCalls=0;
|
||||
assert.equal((await check({X:'public'},p,{inspectLocal:async()=>({decision:'block',ruleId:'semantic-secret'}),inspectEnterprise:async()=>{enterpriseCalls++;}})).ruleId,'semantic-secret');assert.equal(enterpriseCalls,0);
|
||||
let current=true;
|
||||
assert.equal((await check({X:'public'},p,{isCurrent:()=>current,inspectLocal:async()=>{current=false;return {decision:'allow',coverage:{complete:true}};}})).ruleId,'policy-changed');
|
||||
p.dlp.semantic.enabled=false;
|
||||
assert.equal((await check({X:'public'},p,{inspectEnterprise:async()=>({decision:'allow'})})).ruleId,'inspection-incomplete');
|
||||
assert.equal((await check({X:'public'},p,{inspectEnterprise:async()=>({decision:'block',ruleId:'icap-malware'})})).ruleId,'icap-malware');
|
||||
});
|
||||
test('exceptions wipe full-header scan buffers and do not turn failure into allow',async()=>{
|
||||
let held;
|
||||
await assert.rejects(check({Cookie:'ordinary=session'},policy(),{inspectEnterprise:async bytes=>{held=bytes;throw Error('fixture scanner unavailable');}}),/unavailable/);
|
||||
assert.ok(held.every(byte=>byte===0));
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
const {performHistory}=createRequire(import.meta.url)('../apps/browser/history-navigation.cjs');
|
||||
function fixture(entries=['https://example.test/one','https://example.test/two'],index=1){
|
||||
const c=new EventEmitter();c.url=entries[index];c.stops=0;c.calls=0;c.index=index;c.isDestroyed=()=>false;c.getURL=()=>c.url;c.stop=()=>c.stops++;c.navigationHistory={getActiveIndex:()=>c.index,getEntryAtIndex:i=>entries[i]?{url:entries[i]}:null,goToIndex:i=>{c.calls++;c.index=i;c.url=entries[i];}};c.reload=()=>c.calls++;return c;
|
||||
}
|
||||
const run=(contents,extra={})=>performHistory({contents,action:'back',allowed:()=>true,current:()=>true,timeoutMs:100,...extra});
|
||||
const dispatched=async c=>{for(let i=0;i<20&&!c.calls;i++)await new Promise(r=>setImmediate(r));assert.equal(c.calls,1);};
|
||||
test('native back/forward target and main-frame in-page completion',async()=>{const c=fixture();let p=run(c);await dispatched(c);c.emit('did-navigate-in-page',{},c.url,true);assert.deepEqual(await p,{completed:true,url:'https://example.test/one'});c.calls=0;p=run(c,{action:'forward'});await dispatched(c);c.emit('did-finish-load');assert.equal((await p).url,'https://example.test/two');assert.equal(c.eventNames().length,0);});
|
||||
test('missing history is a no-op',async()=>{const c=fixture(['https://example.test/'],0);assert.deepEqual(await run(c),{completed:false,url:c.url});assert.equal(c.calls,0);});
|
||||
test('blocked protocols, credentials and policy reject before side effects',async()=>{for(const url of ['file:///secret','https://user:[email protected]/','https://example.test/']){const c=fixture([url,'https://example.test/two']);await assert.rejects(run(c,{allowed:()=>false}),/denied/);assert.equal(c.calls,0);}});
|
||||
test('history races while awaiting policy cannot change inspected destination',async()=>{const c=fixture();await assert.rejects(run(c,{allowed:async()=>{c.index=0;return true;}}),/destination changed/);assert.equal(c.calls,0);});
|
||||
test('stale context and policy revocation after dispatch stop navigation',async()=>{for(const mode of ['context','policy']){const c=fixture();let valid=true;const p=run(c,{current:()=>mode==='context'?valid:true,allowed:()=>mode==='policy'?valid:true});await dispatched(c);valid=false;c.emit('did-finish-load');await assert.rejects(p,/context changed|destination denied/);assert.equal(c.stops,1);assert.equal(c.eventNames().length,0);}});
|
||||
test('abort before and after dispatch has bounded cleanup',async()=>{const c=fixture();const controller=new AbortController();controller.abort();await assert.rejects(run(c,{signal:controller.signal}),/cancelled/);assert.equal(c.calls,0);const next=new AbortController();const p=run(c,{signal:next.signal});await dispatched(c);next.abort();await assert.rejects(p,/cancelled/);assert.equal(c.stops,1);assert.equal(c.eventNames().length,0);});
|
||||
test('same-URL reload awaits actual event, subframe events ignored',async()=>{const c=fixture();let done=false;const p=run(c,{action:'reload'}).then(v=>{done=true;return v;});await dispatched(c);c.emit('did-navigate-in-page',{},c.url,false);c.emit('did-fail-load',{},-1,'private error',c.url,false);await new Promise(r=>setImmediate(r));assert.equal(done,false);c.emit('did-finish-load');assert.equal((await p).completed,true);});
|
||||
test('unexpected final URL and mainframe load failure reject; timeout stops',async()=>{for(const mode of ['redirect','failure','timeout']){const c=fixture();const p=run(c,{timeoutMs:30});await dispatched(c);if(mode==='redirect'){c.url='https://other.test/';c.emit('did-finish-load');}if(mode==='failure')c.emit('did-fail-load',{},-1,'secret',c.url,true);await assert.rejects(p,/destination changed|navigation failed|timed out/);assert.equal(c.stops,1);assert.equal(c.eventNames().length,0);}});
|
||||
|
||||
test('missing positive native context proof denies history before any side effect',async()=>{const c=fixture();await assert.rejects(run(c,{current:async()=>undefined}),/context changed/);assert.equal(c.calls,0);});
|
||||
test('context guards distinguish captured state before dispatch from intentional navigation after',async()=>{const c=fixture(),phases=[];const p=run(c,{current:phase=>{phases.push(phase);return phase==='before'?c.calls===0:c.calls===1;}});await dispatched(c);c.emit('did-finish-load');assert.equal((await p).completed,true);assert.ok(phases.includes('before')&&phases.includes('after'));});
|
||||
@@ -6,4 +6,4 @@ test('auditor cannot triage and unrecognized routes never gain permissions',asyn
|
||||
test('filters and pagination are bounded and deterministic',async t=>{const {store,api}=await fixture(t);for(let i=0;i<3;i++){store.event(null,'upload.blocked',{});const source=store.db.prepare("SELECT id FROM events WHERE kind='upload.blocked' ORDER BY id DESC LIMIT 1").get();await api('','POST',{eventId:source.id,severity:i===0?'low':'high'});}const first=await api('?limit=1&severity=high');assert.equal(first.body.incidents.length,1);assert.ok(first.body.nextBefore);const next=await api('?limit=1&severity=high&before='+first.body.nextBefore);assert.equal(next.body.incidents.length,1);assert.notEqual(first.body.incidents[0].id,next.body.incidents[0].id);assert.equal(next.body.nextBefore,null);assert.equal((await api('?status=resolved')).body.incidents.length,0);assert.equal((await api('?limit=101')).status,400);assert.equal((await api('?status=bogus')).status,400);});
|
||||
test('audit failure rolls back case creation and triage history atomically',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{});const emit=store.event;store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('','POST',{eventId:1})).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_cases').get().n,0);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,0);store.event=emit;const created=await api('','POST',{eventId:1});store.event=()=>{throw Error('synthetic audit unavailable');};assert.equal((await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved'})).status,500);assert.equal(store.db.prepare('SELECT revision,status FROM incident_cases').get().revision,1);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,1);store.event=emit;});
|
||||
|
||||
test('real resolved incident retains source and immutable history through event retention',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{ruleId:'confidential'});const source=store.db.prepare('SELECT id FROM events ORDER BY id DESC LIMIT 1').get().id;const created=await api('','POST',{eventId:source});const resolved=await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved',comment:'Synthetic resolution note'});assert.equal(resolved.status,200);store.db.prepare('UPDATE events SET time=?').run('2020-01-01T00:00:00.000Z');const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');const result=pruneAuditEvents(store,{days:30,exportedThrough:100,dryRun:false});assert.ok(result.eligible>0);const detail=await api('/'+created.body.id);assert.equal(detail.status,200);assert.equal(detail.body.status,'resolved');assert.equal(detail.body.event.id,source);assert.equal(detail.body.history.length,2);assert.equal(detail.body.history[1].comment,'Synthetic resolution note');assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=?').get(source).kind,'upload.blocked');});
|
||||
test('real resolved incident retains source and immutable history through event retention',async t=>{const {store,api}=await fixture(t);store.event(null,'upload.blocked',{ruleId:'confidential'});const source=store.db.prepare('SELECT id FROM events ORDER BY id DESC LIMIT 1').get().id;const created=await api('','POST',{eventId:source});const resolved=await api('/'+created.body.id,'PATCH',{expectedRevision:1,status:'resolved',comment:'Synthetic resolution note'});assert.equal(resolved.status,200);const {pruneAuditEvents}=await import('../apps/server/audit-retention.mjs');const result=pruneAuditEvents(store,{now:Date.now()+60*86400000,days:30,exportedThrough:100,dryRun:false});assert.ok(result.eligible>0);const detail=await api('/'+created.body.id);assert.equal(detail.status,200);assert.equal(detail.body.status,'resolved');assert.equal(detail.body.event.id,source);assert.equal(detail.body.history.length,2);assert.equal(detail.body.history[1].comment,'Synthetic resolution note');assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=?').get(source).kind,'upload.blocked');});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createLibraryOrganization}=createRequire(import.meta.url)('../apps/browser/library-organization.cjs');
|
||||
function fixture(){
|
||||
const snapshot={bookmarks:[{id:'b1'}],tabs:[{id:'t1'}]},plan={bookmarks:[{id:'b1',folder:'Work'}],tabs:[{id:'t1',group:'Research'}]};
|
||||
let current=true,auto=false,commits=0,applied=0;
|
||||
const f={capture:async()=>snapshot,assertCurrent:()=>{if(!current)throw Error('stale');},inspect:async()=>{},propose:async()=>({proposalId:'p1',plan}),commit:async()=>{commits++;return {revision:2};},applyTabs:()=>{applied++;},audit:async()=>{},autoAllowed:()=>auto};
|
||||
return {f,plan,stale:()=>current=false,auto:()=>auto=true,counts:()=>({commits,applied})};
|
||||
}
|
||||
test('native organization requires review and pins authoritative plan',async()=>{const x=fixture(),o=createLibraryOrganization(x.f);const result=await o.plan();result.plan.tabs[0].group='Hostile';await assert.rejects(o.apply({proposalId:'p1'}),/Review/);await o.apply({proposalId:'p1',confirm:true});assert.deepEqual(x.counts(),{commits:1,applied:1});await assert.rejects(o.apply({proposalId:'p1',confirm:true}),/expired/);});
|
||||
test('native stale context blocks before central commit',async()=>{const x=fixture(),o=createLibraryOrganization(x.f);await o.plan();x.stale();await assert.rejects(o.apply({proposalId:'p1',confirm:true}),/stale/);assert.deepEqual(x.counts(),{commits:0,applied:0});});
|
||||
test('native postcommit context change explicitly leaves tabs untouched',async()=>{const x=fixture();x.f.commit=async()=>{x.stale();return {revision:2};};const o=createLibraryOrganization(x.f);await o.plan();await assert.rejects(o.apply({proposalId:'p1',confirm:true}),/Bookmarks were organized centrally; tabs were unchanged/);assert.equal(x.counts().applied,0);});
|
||||
test('model cannot introduce URL, unknown ID or arbitrary properties',async()=>{const x=fixture();x.f.propose=async()=>({proposalId:'p1',plan:{bookmarks:[{id:'b1',folder:'Work',url:'https://evil.test'}],tabs:[{id:'t1',group:'R'}]}});await assert.rejects(createLibraryOrganization(x.f).plan());x.f.propose=async()=>({proposalId:'p1',plan:{bookmarks:[{id:'unknown',folder:'Work'}],tabs:[{id:'t1',group:'R'}]}});await assert.rejects(createLibraryOrganization(x.f).plan());});
|
||||
test('automatic apply requires central local-only permission',async()=>{const x=fixture(),o=createLibraryOrganization(x.f);await o.plan();await assert.rejects(o.apply({proposalId:'p1',autoApply:true}),/Review/);x.auto();await o.apply({proposalId:'p1',autoApply:true});assert.equal(x.counts().applied,1);});
|
||||
test('pending organization serializes library operations and releases reservation on failure',async()=>{const x=fixture();let release;x.f.propose=()=>new Promise(resolve=>release=resolve);const o=createLibraryOrganization(x.f),pending=o.plan();await new Promise(resolve=>setImmediate(resolve));assert.equal(o.isBusy(),true);await assert.rejects(o.plan(),/already running/);release({proposalId:'p1',plan:x.plan});await pending;assert.equal(o.isBusy(),false);});
|
||||
test('aborted organization cannot commit or apply native groups',async()=>{const x=fixture(),o=createLibraryOrganization(x.f),controller=new AbortController();await o.plan();controller.abort();await assert.rejects(o.apply({proposalId:'p1',confirm:true},{signal:controller.signal}),/cancelled/);assert.deepEqual(x.counts(),{commits:0,applied:0});});
|
||||
test('proposal binding cannot be replaced by an unrelated snapshot',async()=>{const x=fixture();x.f.capture=async()=>({bookmarks:[{id:'b1'}],tabs:[{id:'t1'}],snapshotHash:'a'.repeat(64),policyVersion:1,bookmarkRevision:1});x.f.propose=async()=>({proposalId:'p1',plan:x.plan,binding:{snapshotHash:'b'.repeat(64),policyVersion:1,bookmarkRevision:1}});await assert.rejects(createLibraryOrganization(x.f).plan(),/binding/);});
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createServer} from 'node:http';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {librarySnapshotHash,validateLibraryPlan,LibraryPolicySchema} from '../packages/core/library.mjs';
|
||||
import {verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs';
|
||||
|
||||
const bookmark={id:'personal',title:'Public project',url:'https://example.com/project',folder:''};
|
||||
const tab={id:'tab-1',title:'Public documentation',url:'https://docs.example.com/path?session=private#secret'};
|
||||
const plan={bookmarks:[{id:'personal',folder:'Projects'}],tabs:[{id:'tab-1',group:'Research'}]};
|
||||
async function fixture(t){
|
||||
const dir=mkdtempSync(join(tmpdir(),'library-test-')),store=openStore(dir),adminToken='library-fixture-admin';let output=JSON.stringify(plan),calls=0,prompt='',gate=null;
|
||||
const provider=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);prompt=JSON.parse(Buffer.concat(chunks).toString()).messages[0].content;calls++;if(gate)await gate;res.setHeader('content-type','application/json');res.end(JSON.stringify({choices:[{message:{content:output}}]}));});
|
||||
await new Promise(r=>provider.listen(0,'127.0.0.1',r));
|
||||
const config={adminToken,publicUrl:'http://127.0.0.1',secure:false,aiBaseUrl:`http://127.0.0.1:${provider.address().port}`,aiModel:'fixture',aiEndpointId:'default'};
|
||||
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));
|
||||
t.after(async()=>{await Promise.all([new Promise(r=>server.close(r)),new Promise(r=>provider.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
async function api(route,body,token=adminToken){const r=await fetch(`http://127.0.0.1:${server.address().port}${route}`,{method:body?'POST':'GET',headers:{authorization:`Bearer ${token}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}
|
||||
const code=(await api('/api/admin/enrollments',{user:'fixture-user'})).body.code,device=(await api('/api/device/enroll',{code,name:'Fixture',platform:'win32'},'')).body;
|
||||
const p=store.get('policy');p.ai={...p.ai,enabled:true,trustMode:'local',model:'fixture'};p.library={aiSuggestionsEnabled:true,aiAutoApplyEnabled:false};store.put('policy',p);
|
||||
await api('/api/device/bookmarks',{action:'add',expectedRevision:0,policyVersion:1,bookmark},device.token);
|
||||
const request={policyVersion:1,bookmarkRevision:1,tabs:[tab],snapshotHash:librarySnapshotHash([tab])};
|
||||
return {store,device,api,request,propose:()=>api('/api/device/library/propose',request,device.token),setOutput:v=>{output=v;},setGate:v=>{gate=v;},calls:()=>calls,prompt:()=>prompt};
|
||||
}
|
||||
test('AI library defaults disabled and strict organization permits only existing item IDs and labels',()=>{
|
||||
assert.deepEqual(LibraryPolicySchema.parse({}),{aiSuggestionsEnabled:false,aiAutoApplyEnabled:false});assert.deepEqual(validateLibraryPlan(plan,{bookmarks:[bookmark],tabs:[tab]}),plan);
|
||||
for(const v of [{...plan,bookmarks:[{...plan.bookmarks[0],url:'https://evil.test'}]},{...plan,tabs:[{id:'other',group:'Research'}]},{...plan,bookmarks:[]},{...plan,tabs:[{id:tab.id,group:'a'.repeat(41)}]},{...plan,bookmarks:[{id:bookmark.id,folder:'bad\nlabel'}]}])assert.throws(()=>validateLibraryPlan(v,{bookmarks:[bookmark],tabs:[tab]}));
|
||||
});
|
||||
test('approved gateway proposes metadata-only AI plan and atomically applies signed personal bookmarks',async t=>{
|
||||
const f=await fixture(t),result=await f.propose();assert.equal(result.status,200);assert.ok(f.prompt().includes('docs.example.com'));assert.ok(!f.prompt().includes('/path'));assert.ok(!f.prompt().includes('session=private'));assert.ok(!f.prompt().includes('/project'));
|
||||
const applied=await f.api('/api/device/library/apply',{proposalId:result.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash},f.device.token);assert.equal(applied.status,200);assert.equal(applied.body.revision,2);assert.equal(applied.body.items[0].folder,'Projects');assert.deepEqual(applied.body.tabs,plan.tabs);
|
||||
assert.equal(verifyBookmarkSnapshot(applied.body.bookmarks,f.device.publicKey,{deviceId:f.device.id,policyVersion:1,minimumRevision:2}).items[0].folder,'Projects');
|
||||
assert.equal((await f.api('/api/device/library/apply',{proposalId:result.body.proposalId,policyVersion:1,expectedRevision:2,snapshotHash:f.request.snapshotHash},f.device.token)).status,404);
|
||||
const events=f.store.db.prepare("SELECT metadata FROM events WHERE kind LIKE 'library.%'").all();assert.equal(events.length,2);assert.ok(events.every(e=>!e.metadata.includes(bookmark.title)&&!e.metadata.includes(bookmark.url)&&!e.metadata.includes('Projects')));
|
||||
});
|
||||
test('central disable, hidden library, expired policy, malformed snapshot and disabled automatic application fail closed',async t=>{
|
||||
const f=await fixture(t),p=f.store.get('policy');p.library.aiSuggestionsEnabled=false;f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.calls(),0);
|
||||
p.library.aiSuggestionsEnabled=true;p.browser.settings.hiddenSections=['library'];f.store.put('policy',p);assert.equal((await f.propose()).status,403);p.browser.settings.hiddenSections=[];f.store.put('policy',p);
|
||||
assert.equal((await f.api('/api/device/library/propose',{...f.request,snapshotHash:'0'.repeat(64)},f.device.token)).status,409);const r=await f.propose();assert.equal(r.status,200);
|
||||
assert.equal((await f.api('/api/device/library/apply',{proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash,mode:'automatic'},f.device.token)).status,403);
|
||||
p.expiresAt=new Date(Date.now()-1000).toISOString();f.store.put('policy',p);assert.equal((await f.propose()).status,403);
|
||||
});
|
||||
test('malicious model output and protected metadata rejected before proposal or transmission',async t=>{
|
||||
const f=await fixture(t);for(const bad of [{...plan,bookmarks:[{id:'managed',folder:'Work'}]},{...plan,delete:['personal']},{...plan,tabs:[{...plan.tabs[0],url:'https://evil.test'}]}]){f.setOutput(JSON.stringify(bad));assert.notEqual((await f.propose()).status,200);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);}
|
||||
const before=f.calls(),p=f.store.get('policy');p.dlp.rules.push({id:'confidential-title',label:'Protected title',kind:'literal',value:bookmark.title,action:'block'});f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.calls(),before);
|
||||
});
|
||||
test('stale bookmark revision, policy generation and managed collisions cannot apply or partially change library',async t=>{
|
||||
const f=await fixture(t),r=await f.propose();assert.equal(r.status,200);const apply={proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash};
|
||||
assert.equal((await f.api('/api/device/library/apply',{...apply,snapshotHash:'0'.repeat(64)},f.device.token)).status,409);
|
||||
f.store.db.prepare('UPDATE bookmarks SET revision=2 WHERE device_id=?').run(f.device.id);assert.equal((await f.api('/api/device/library/apply',apply,f.device.token)).status,409);assert.equal(f.store.personalBookmarks(f.device.id).items[0].folder,'');
|
||||
f.store.db.prepare('UPDATE bookmarks SET revision=1 WHERE device_id=?').run(f.device.id);const p=f.store.get('policy');p.version=2;f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',{...apply,policyVersion:2},f.device.token)).status,409);
|
||||
p.version=1;p.browser.bookmarks.managed=[bookmark];f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',apply,f.device.token)).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||
});
|
||||
test('automatic organization is separately enabled and existing proposals cannot survive a remote disable',async t=>{
|
||||
const f=await fixture(t),p=f.store.get('policy');p.library.aiAutoApplyEnabled=true;f.store.put('policy',p);const r=await f.propose();assert.equal(r.status,200);
|
||||
const body={proposalId:r.body.proposalId,policyVersion:1,expectedRevision:1,snapshotHash:f.request.snapshotHash,mode:'automatic'};
|
||||
p.library.aiSuggestionsEnabled=false;f.store.put('policy',p);assert.equal((await f.api('/api/device/library/apply',body,f.device.token)).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||
p.library.aiSuggestionsEnabled=true;f.store.put('policy',p);const applied=await f.api('/api/device/library/apply',body,f.device.token);assert.equal(applied.status,200);assert.equal(applied.body.revision,2);
|
||||
});
|
||||
test('model output DLP and pending proposal bounds are enforced without storing rejected model data',async t=>{
|
||||
const f=await fixture(t),p=f.store.get('policy');p.dlp.rules.push({id:'protected-output',label:'Protected',kind:'literal',value:'Projects',action:'block'});f.store.put('policy',p);assert.equal((await f.propose()).status,403);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||
p.dlp.rules=[];f.store.put('policy',p);for(let n=0;n<3;n++)assert.equal((await f.propose()).status,200);const before=f.calls();assert.equal((await f.propose()).status,409);assert.equal(f.calls(),before);
|
||||
});
|
||||
test('concurrent proposal requests are bounded and policy changes during provider execution invalidate results',async t=>{
|
||||
const f=await fixture(t);let release;f.setGate(new Promise(r=>{release=r;}));const pending=f.propose();
|
||||
for(let n=0;n<100&&f.calls()===0;n++)await new Promise(r=>setTimeout(r,10));assert.equal(f.calls(),1);
|
||||
assert.equal((await f.propose()).status,409);assert.equal(f.calls(),1);
|
||||
const p=f.store.get('policy');p.version++;f.store.put('policy',p);release();assert.equal((await pending).status,409);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||
test('central ingestion strips legacy upload body hashes and retains safe request facts',async t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-request-audit-')),store=openStore(dir),credential='synthetic-device-request-audit-token';
|
||||
store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('device','Synthetic',digest(credential));
|
||||
const server=createApp(store,{publicUrl:'http://127.0.0.1',adminToken:'synthetic-administrator-token-123456789',secure:false}).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-request-audit-')));rmSync(dir,{recursive:true,force:true});});
|
||||
const send=body=>fetch(`http://127.0.0.1:${server.address().port}/api/device/events`,{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify({eventId:randomUUID(),policyVersion:1,...body})});
|
||||
const hash=digest('synthetic-low-entropy-password');let response=await send({kind:'upload.authorized',file:{bytes:32,detectedType:'text',sha256:hash}});assert.equal(response.status,200);
|
||||
let row=store.db.prepare('SELECT * FROM events').get(),metadata=JSON.parse(row.metadata);assert.equal(metadata.file,undefined);assert.deepEqual(metadata.request,{bytes:32,detectedType:'text'});assert.equal(row.metadata.includes(hash),false);
|
||||
response=await send({kind:'upload.block',request:{bytes:50,detectedType:'unknown'}});assert.equal(response.status,200);
|
||||
response=await send({kind:'clipboard.copy',request:{bytes:50,detectedType:'text'}});assert.equal(response.status,400);
|
||||
response=await send({kind:'upload.authorized',request:{bytes:50,detectedType:'text',sha256:hash}});assert.equal(response.status,400);
|
||||
response=await send({kind:'download.scan',file:{bytes:32,detectedType:'text',sha256:hash}});assert.equal(response.status,200);row=store.db.prepare("SELECT * FROM events WHERE kind='download.scan'").get();assert.equal(JSON.parse(row.metadata).file.sha256,hash);assert.equal(store.auditIntegrity.verify().valid,true);
|
||||
});
|
||||
test('SIEM suppresses historical upload hashes while preserving transfer amounts and download hashes',()=>{
|
||||
const hash=digest('synthetic-password'),legacy={id:1,time:new Date().toISOString(),kind:'upload.authorized',metadata:{file:{bytes:18,detectedType:'text',sha256:hash}}};
|
||||
assert.equal(toECS(legacy).file,undefined);assert.deepEqual(toECS(legacy).safetynet.request,{bytes:18,detected_type:'text'});assert.equal(toCEF(legacy).includes(hash),false);
|
||||
const current={...legacy,metadata:{request:{bytes:18,detectedType:'text'}}};assert.deepEqual(toECS(current).safetynet.request,{bytes:18,detected_type:'text'});assert.match(toCEF(current),/cn3=18/);assert.equal(JSON.stringify(toECS(current)).includes(hash),false);
|
||||
legacy.kind='download.scan';assert.equal(toECS(legacy).file.hash.sha256,hash);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
const hook=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequest("),source.indexOf(" browserSession.webRequest.onBeforeSendHeaders("));
|
||||
function fixture(){
|
||||
let listener,completeDNS;const timers=[],events=[],callbacks=[],policy=defaultPolicy();
|
||||
const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),URL,Date,ready:true,policy,
|
||||
browserSession:{webRequest:{onBeforeRequest(_filter,fn){listener=fn;}}},tabs:{all:()=>[]},currentTabContext:()=>true,
|
||||
developerAssetRequestAllowed:()=>false,diagnosticSpan:(_name,_attrs,fn)=>fn(),core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})},
|
||||
audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>completeDNS=resolve),
|
||||
setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){},Buffer,
|
||||
});
|
||||
vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context);
|
||||
vm.runInContext(hook,context);listener({id:1,url:'https://example.test/',webContentsId:0,resourceType:'mainFrame',method:'GET'},r=>callbacks.push(r));
|
||||
return {timers,events,callbacks,complete:()=>completeDNS()};
|
||||
}
|
||||
const settle=async()=>{for(let n=0;n<8;n++)await Promise.resolve();};
|
||||
test('native request deadline records a fixed reason and never authorizes a late result',async()=>{
|
||||
const f=fixture();await settle();f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true);
|
||||
assert.deepEqual(f.events,[['navigation.block','request-inspection-timeout','example.test']]);f.complete();await settle();f.timers[0]();await settle();
|
||||
assert.equal(f.callbacks.length,1);assert.equal(f.events.length,1);
|
||||
});
|
||||
test('a completed native request cannot subsequently produce a timeout event',async()=>{
|
||||
const f=fixture();await settle();f.complete();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false);
|
||||
f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.events.length,0);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{requestSourceCurrent}=require('../apps/browser/request-source.cjs'),{createTabRegistry}=require('../apps/browser/tab-registry.cjs');
|
||||
function fixture(){
|
||||
let url='https://old.example/',destroyed=false;
|
||||
const contents={id:7,isDestroyed:()=>destroyed,getURL:()=>url},registry=createTabRegistry({createView:()=>({webContents:contents,setVisible(){}})}),source=registry.create();
|
||||
source.guarded=true;registry.resume(1);const token=registry.context(source.id,{foreground:false});
|
||||
const input={details:{webContentsId:7,resourceType:'mainFrame',method:'GET'},source,token,registry,currentDocument:t=>registry.current(t)};
|
||||
return {input,registry,source,setURL:value=>url=value,destroy:()=>destroyed=true};
|
||||
}
|
||||
test('top-level empty GET retains native ownership across the old document revision and URL transition',()=>{
|
||||
const f=fixture();f.registry.navigation(f.source.id);f.setURL('https://new.example/');
|
||||
assert.equal(f.registry.current(f.input.token),false);assert.equal(requestSourceCurrent(f.input),true);
|
||||
});
|
||||
test('POST, XHR, worker and body-bearing requests retain exact document generation checks',()=>{
|
||||
for(const change of [{method:'POST'},{resourceType:'xhr'},{resourceType:'other'},{uploadData:[{bytes:Buffer.from('data')}]},{method:'HEAD'}]){
|
||||
const f=fixture();Object.assign(f.input.details,change);f.registry.navigation(f.source.id);assert.equal(requestSourceCurrent(f.input),false);
|
||||
}
|
||||
});
|
||||
test('top-level GET cannot substitute, remove, destroy or unguard its native owner',()=>{
|
||||
const f=fixture();f.source.guarded=false;assert.equal(requestSourceCurrent(f.input),false);f.source.guarded=true;f.destroy();assert.equal(requestSourceCurrent(f.input),false);
|
||||
const g=fixture();g.input.registry={fromContents:()=>({view:g.source.view,guarded:true})};assert.equal(requestSourceCurrent(g.input),false);
|
||||
const h=fixture();h.input.source={...h.source,view:{webContents:{isDestroyed:()=>false}}};assert.equal(requestSourceCurrent(h.input),false);
|
||||
const missing=fixture();missing.input.token=null;assert.equal(requestSourceCurrent(missing.input),false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import facts from '../packages/core/request-transfer-facts.cjs';
|
||||
import transfers from '../packages/core/file-transfer.cjs';
|
||||
|
||||
for(const [name,body] of [
|
||||
['form password',Buffer.from('username=alice&password=short-secret')],
|
||||
['JSON token',Buffer.from('{"token":"short-secret"}')],
|
||||
['binary credential envelope',Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),Buffer.from('password=short-secret')])],
|
||||
['multipart secret',Buffer.from('--boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\nshort-secret\r\n--boundary--')]
|
||||
])test(`request audit excludes reversible fingerprint for ${name}`,()=>{
|
||||
const transfer=transfers.inspectFileTransfer(body,{},'upload');
|
||||
const result=facts.requestFactsForTransfer(transfer);
|
||||
assert.deepEqual(Object.keys(result).sort(),['bytes','detectedType']);
|
||||
assert.equal(result.bytes,body.length);
|
||||
assert.equal(JSON.stringify(result).includes(transfer.sha256),false);
|
||||
assert.equal(JSON.stringify(result).includes('short-secret'),false);
|
||||
assert.throws(()=>facts.requestTransferFacts({...result,sha256:transfer.sha256}));
|
||||
});
|
||||
|
||||
test('request facts reject arbitrary content, unknown types and invalid sizes',()=>{
|
||||
for(const value of [{bytes:-1,detectedType:'text'},{bytes:1,detectedType:'password'},{bytes:1,detectedType:'text',content:'secret'}])assert.throws(()=>facts.requestTransferFacts(value));
|
||||
});
|
||||
|
||||
test('privileged request gate routes upload audit through request facts without modifying file inspection',()=>{
|
||||
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
assert.match(source,/requestFactsForTransfer\(transfer\)/);
|
||||
assert.match(source,/audit\('upload\.authorized',null,new URL\(details\.url\)\.hostname,undefined,requestFacts\)/);
|
||||
assert.match(source,/audit\('upload\.block',transfer\.ruleId,new URL\(details\.url\)\.hostname,undefined,requestFacts\)/);
|
||||
assert.match(source,/core\.inspect\(requestBytes,policy\)/);
|
||||
assert.equal(source.includes('sha256:transfer.sha256'),false);
|
||||
});
|
||||
|
||||
|
||||
test('offline legacy upload queue is scrubbed before replay without changing delivery identity or downloads',()=>{
|
||||
const download={eventId:'download-id',kind:'download.released',file:{bytes:9,detectedType:'text',sha256:'b'.repeat(64)}};
|
||||
const legacy={eventId:'upload-id',kind:'upload.authorized',host:'example.test',policyVersion:7,file:{bytes:9,detectedType:'text',sha256:'a'.repeat(64)}};
|
||||
const migrated=facts.migrateQueuedRequestFacts([legacy,download]);
|
||||
assert.deepEqual(migrated[0],{eventId:'upload-id',kind:'upload.authorized',host:'example.test',policyVersion:7,request:{bytes:9,detectedType:'text'}});
|
||||
assert.equal(migrated[1],download);
|
||||
assert.equal(legacy.file.sha256,'a'.repeat(64));
|
||||
assert.deepEqual(facts.migrateQueuedRequestFacts(migrated),migrated);
|
||||
const invalid=facts.migrateQueuedRequestFacts([{kind:'upload.block',eventId:'blocked-id',file:{bytes:-1,detectedType:'secret',sha256:'secret'}}]);
|
||||
assert.deepEqual(invalid,[{kind:'upload.block',eventId:'blocked-id'}]);
|
||||
assert.throws(()=>facts.migrateQueuedRequestFacts({}));
|
||||
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
assert.match(source,/migrateQueuedRequestFacts\(JSON.parse\(safeStorage.decryptString\(fs.readFileSync\(queuePath\)\)\)\);persistAudit\(\)/);
|
||||
});
|
||||
@@ -85,7 +85,7 @@ test('local Ollama needs no API key, blocks secrets before provider, and Sigma a
|
||||
p.dlp.rules=[{id:'company-roadmap',label:'Roadmap',kind:'literal',value:'confidential roadmap',action:'block'}];store.put('policy',p);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,403);assert.equal(calls,1);
|
||||
p.ai.allowConfidentialLocal=true;store.put('policy',p);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,200);assert.equal(calls,2);assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap -----BEGIN PRIVATE KEY-----'})).status,403);assert.equal(calls,2);
|
||||
const allowed=store.db.prepare("SELECT metadata FROM events WHERE kind='ai.decision' ORDER BY id DESC").all().map(v=>JSON.parse(v.metadata)).find(v=>v.classification==='local-only');assert.equal(allowed.ruleId,'company-roadmap');assert.equal(allowed.decision,'allow');
|
||||
config.aiBaseUrl='http://192.0.2.1';assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,500);assert.equal(calls,2);
|
||||
config.aiBaseUrl='http://192.0.2.1';assert.equal((await send('/api/device/ai',{prompt:'confidential roadmap'})).status,403);assert.equal(calls,2);
|
||||
assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind:'agent.denied',ruleId:'agent:read:blocked',policyVersion:1})).status,200);assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1);
|
||||
for(const extra of [{username:'alice'},{password:'private'},{credentialId:'identifier'},{host:'login.example'},{ruleId:'credential-id'}])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind:'vault.fill-requested',policyVersion:1,...extra})).status,400);
|
||||
for(const kind of ['vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested'])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1})).status,200);
|
||||
|
||||
@@ -38,3 +38,52 @@ test('denied agent actions and correlated Sigma alerts export durably without ra
|
||||
exporter=startSIEM(store,directory,{});await exporter.flush();const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(JSON.parse);assert.equal(rows.length,2);assert.equal(rows[1].event.action,'sigma.alert');assert.equal(rows[1].safetynet.source_event_id,rows[0].event.id);assert.equal(JSON.stringify(rows).includes('excluded raw content'),false);
|
||||
store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked'});store.db.exec('ROLLBACK');assert.equal(store.db.prepare('SELECT count(*) AS n FROM events').get().n,2);assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1);
|
||||
});
|
||||
|
||||
test('administrator forwarding toggle stops HTTPS export, preserves audit, resumes cursor after restart',async t=>{
|
||||
const {createServer}=await import('node:https');
|
||||
const certPath=new URL('./fixtures/icap/cert.pem',import.meta.url),keyPath=new URL('./fixtures/icap/key.pem',import.meta.url);
|
||||
const batches=[];let exporter;
|
||||
const receiver=createServer({cert:readFileSync(certPath),key:readFileSync(keyPath)},(req,res)=>{let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=>{batches.push(body.trim().split('\n').map(JSON.parse));res.end('{}');});});
|
||||
await new Promise(resolve=>receiver.listen(0,'127.0.0.1',resolve));
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-toggle-')),store=openStore(directory);
|
||||
t.after(async()=>{exporter?.stop();await new Promise(resolve=>receiver.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
const env={SIEM_HTTP_URL:`https://127.0.0.1:${receiver.address().port}/events`,SIEM_CA_FILE:certPath};
|
||||
store.event('device','download.scan',{ruleId:'synthetic'});exporter=startSIEM(store,directory,env);await exporter.flush();assert.equal(exporter.status().cursor,1);
|
||||
const disabled=exporter.setEnabled({enabled:false,expectedRevision:1},'admin');assert.equal(disabled.enabled,false);
|
||||
store.event('device','clipboard.copy',{});await exporter.flush();assert.equal(batches.length,1);assert.equal(exporter.status().cursor,1);assert.equal(exporter.status().pending,2);
|
||||
assert.throws(()=>exporter.setEnabled({enabled:true,expectedRevision:1},'admin'),/revision conflict/);
|
||||
exporter.stop();exporter=startSIEM(store,directory,env);await exporter.flush();assert.equal(exporter.status().enabled,false);assert.equal(batches.length,1);
|
||||
exporter.setEnabled({enabled:true,expectedRevision:2},'admin');await exporter.flush();assert.equal(batches.length,2);assert.equal(exporter.status().pending,0);
|
||||
assert.deepEqual(batches.flat().map(e=>e.event.id),[1,2,3,4].map(id=>`${store.get('installationId')}:${id}`));
|
||||
assert.equal(store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='siem.forwarding-changed'").get().n,2);
|
||||
assert.equal(JSON.stringify(exporter.status()).includes('/events'),false);
|
||||
});
|
||||
|
||||
test('disabling forwarding aborts an unacknowledged HTTPS request without advancing cursor',async t=>{
|
||||
const {createServer}=await import('node:https');let arrived,closed;const arrival=new Promise(resolve=>arrived=resolve),closure=new Promise(resolve=>closed=resolve);
|
||||
const receiver=createServer({cert:readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key:readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url))},(req,res)=>{req.resume();res.on('close',closed);req.on('end',arrived);});
|
||||
await new Promise(resolve=>receiver.listen(0,'127.0.0.1',resolve));
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-abort-')),store=openStore(directory);let exporter;
|
||||
t.after(async()=>{exporter?.stop();receiver.closeAllConnections();await new Promise(resolve=>receiver.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
store.event('device','download.scan',{});exporter=startSIEM(store,directory,{SIEM_HTTP_URL:`https://127.0.0.1:${receiver.address().port}`,SIEM_CA_FILE:new URL('./fixtures/icap/cert.pem',import.meta.url)});
|
||||
await arrival;const active=exporter.flush();exporter.setEnabled({enabled:false,expectedRevision:1},'admin');await active;await closure;
|
||||
assert.equal(exporter.status().cursor,0);assert.equal(exporter.status().failures,0);assert.equal(exporter.status().pending,2);
|
||||
});
|
||||
|
||||
test('SIEM management API enforces authentication, administrator role, origin, strict settings and CAS',async t=>{
|
||||
const {createApp}=await import('../apps/server/app.mjs');const {digest}=await import('../apps/server/store.mjs');
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-api-')),store=openStore(directory),exporter=startSIEM(store,directory,{SIEM_ENABLED:'false'});
|
||||
const adminToken='synthetic-admin-credential-012345678901234567890';const config={publicUrl:'http://127.0.0.1:4310',adminToken,secure:false,siemControl:exporter};
|
||||
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.on('listening',resolve));
|
||||
t.after(async()=>{exporter.stop();await new Promise(resolve=>server.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
async function api(method,body,headers={authorization:`Bearer ${adminToken}`}){const r=await fetch(base+'/api/admin/siem',{method,headers:{...headers,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}
|
||||
assert.equal((await api('GET',null,{})).status,401);const initial=await api('GET');assert.equal(initial.status,200);assert.equal(initial.body.enabled,false);
|
||||
for(const role of ['operator','auditor']){const session=`synthetic-${role}-session-012345678901234567890`;store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(session),Date.now()+60000,JSON.stringify({sub:role,role}));const headers={cookie:`sn_session=${session}`,origin:config.publicUrl};assert.equal((await api('GET',null,headers)).status,200);assert.equal((await api('PUT',{enabled:true,expectedRevision:1},headers)).status,403);}
|
||||
assert.equal((await api('PUT',{enabled:true,expectedRevision:1},{authorization:`Bearer ${adminToken}`,origin:'https://untrusted.example'})).status,403);
|
||||
assert.equal((await api('PUT',{enabled:true,expectedRevision:1,endpoint:'https://untrusted.example'})).status,400);
|
||||
const enabled=await api('PUT',{enabled:true,expectedRevision:1});assert.equal(enabled.status,200);assert.equal(enabled.body.revision,2);
|
||||
assert.equal((await api('PUT',{enabled:false,expectedRevision:1})).status,409);assert.equal((await api('GET')).body.enabled,true);
|
||||
assert.equal(store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='siem.forwarding-changed'").get().n,1);
|
||||
config.siemControl=null;assert.equal((await api('GET')).status,503);assert.equal((await api('PUT',{enabled:false,expectedRevision:2})).status,503);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'apps'/'inspector'))
|
||||
from stage_timings import collect, stage, metadata, validated
|
||||
|
||||
class StageTimingsTests(unittest.TestCase):
|
||||
def test_nested_debug(self):
|
||||
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
|
||||
with stage('inspect'):
|
||||
with stage('parse'): pass
|
||||
value=metadata(state)
|
||||
self.assertIs(validated(value),value)
|
||||
self.assertEqual(value['records'][1]['parentId'],1)
|
||||
self.assertGreaterEqual(value['records'][0]['durationMs'],value['records'][1]['durationMs'])
|
||||
def test_off_and_operational(self):
|
||||
for policy in ({},{'diagnostics':{'enabled':False,'mode':'debug'}},{'diagnostics':{'enabled':True,'mode':'operational'}}):
|
||||
with collect(policy) as state:
|
||||
with stage('inspect'): pass
|
||||
self.assertIsNone(metadata(state))
|
||||
def test_error_preserved_and_bounded(self):
|
||||
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
|
||||
for _ in range(100):
|
||||
with stage('parse'): pass
|
||||
self.assertEqual(len(metadata(state)['records']),64)
|
||||
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
|
||||
with self.assertRaisesRegex(ValueError,'secret'):
|
||||
with stage('inspect'): raise ValueError('secret')
|
||||
self.assertEqual(state['records'][0]['status'],'error')
|
||||
self.assertNotIn('secret',str(metadata(state)))
|
||||
def test_invalid_metadata(self):
|
||||
for value in ({}, {'baseUnixMs':1,'records':[{'content':'secret'}]}, {'baseUnixMs':True,'records':[]}):
|
||||
self.assertIsNone(validated(value))
|
||||
with collect({'diagnostics':{'enabled':True,'mode':'debug'}}) as state:
|
||||
with stage('parse'): pass
|
||||
value=metadata(state); value['records'][0]['durationMs']=float('nan')
|
||||
self.assertIsNone(validated(value))
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
@@ -1,14 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { startTelemetry,tracer,recordSecurityEvent } from '../apps/server/telemetry.mjs';
|
||||
import express from 'express';
|
||||
import { startTelemetry,tracer,recordSecurityEvent,installRequestTelemetry,traceOperation,importInspectionStages } from '../apps/server/telemetry.mjs';
|
||||
test('OpenTelemetry exports traces, metrics and safe logs through OTLP HTTP',{timeout:20000},async t=>{
|
||||
const requests=[];
|
||||
const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({url:req.url,data:Buffer.concat(chunks)});res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');
|
||||
await new Promise(r=>server.on('listening',r));process.env.OTEL_EXPORTER_OTLP_ENDPOINT=`http://127.0.0.1:${server.address().port}`;
|
||||
t.after(async()=>{delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;server.closeAllConnections();await new Promise(r=>server.close(r));});
|
||||
const sdk=startTelemetry();t.after(()=>sdk.shutdown());tracer.startActiveSpan('inspection.test',span=>{span.setAttribute('dlp.decision','block');span.end();});
|
||||
const app=express();installRequestTelemetry(app);app.post('/api/inspect/:id',async(req,res)=>{await traceOperation('ai.inspect.input',{bytes:17,prompt:'HTTP_SECRET_MUST_NOT_APPEAR'},async()=>new Promise(r=>setTimeout(r,15)));importInspectionStages({baseUnixMs:Date.now()-5,records:[{id:1,parentId:null,stage:'inspect',startOffsetMs:0,durationMs:4,status:'ok'},{id:2,parentId:1,stage:'parse',startOffsetMs:1,durationMs:2,status:'ok'}]},{diagnostics:{enabled:true,mode:'debug'}});res.json({ok:true});});
|
||||
const application=app.listen(0,'127.0.0.1');await new Promise(r=>application.once('listening',r));t.after(async()=>{application.closeAllConnections();await new Promise(r=>application.close(r));});
|
||||
const remoteTrace='1234567890abcdef1234567890abcdef',remoteParent='abcdef1234567890';
|
||||
await fetch(`http://127.0.0.1:${application.address().port}/api/inspect/HTTP_SECRET_MUST_NOT_APPEAR?token=HTTP_SECRET_MUST_NOT_APPEAR`,{method:'POST',headers:{traceparent:`00-${remoteTrace}-${remoteParent}-01`,authorization:'Bearer HTTP_SECRET_MUST_NOT_APPEAR'},body:'HTTP_SECRET_MUST_NOT_APPEAR'});
|
||||
await fetch(`http://127.0.0.1:${application.address().port}/api/inspect/invalid`,{method:'POST',headers:{traceparent:'00-'+ '0'.repeat(32)+'-'+ '0'.repeat(16)+'-01'}});
|
||||
recordSecurityEvent('dlp.block',{ruleId:'test',policyVersion:1,prompt:'THIS_SECRET_MUST_NOT_APPEAR'},'event-1');await sdk.shutdown();
|
||||
for(const path of ['/v1/traces','/v1/metrics','/v1/logs'])assert.ok(requests.some(r=>r.url===path),`Missing ${path}`);
|
||||
assert.equal(requests.some(r=>r.data.includes(Buffer.from('THIS_SECRET_MUST_NOT_APPEAR'))),false);
|
||||
for(const secret of ['THIS_SECRET_MUST_NOT_APPEAR','HTTP_SECRET_MUST_NOT_APPEAR'])assert.equal(requests.some(r=>r.data.includes(Buffer.from(secret))),false);
|
||||
const spans=requests.filter(r=>r.url==='/v1/traces').flatMap(r=>JSON.parse(r.data).resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);
|
||||
const request=spans.find(s=>s.name==='server.request'&&s.traceId===remoteTrace);assert.ok(request);assert.equal(request.parentSpanId,remoteParent);assert.ok(request.attributes.some(a=>a.key==='http.route'&&a.value.stringValue==='/api/inspect/:id'));
|
||||
const stage=spans.find(s=>s.name==='ai.inspect.input'&&s.traceId===remoteTrace);assert.ok(stage);assert.equal(stage.parentSpanId,request.spanId);assert.ok(stage.attributes.some(a=>a.key==='safetynet.duration.ms'&&a.value.doubleValue>=10));
|
||||
const internal=spans.find(s=>s.name==='inspector.inspect'&&s.traceId===remoteTrace),parse=spans.find(s=>s.name==='inspector.parse'&&s.traceId===remoteTrace);assert.ok(internal&&parse);assert.equal(internal.parentSpanId,request.spanId);assert.equal(parse.parentSpanId,internal.spanId);
|
||||
assert.ok(spans.some(s=>s.name==='server.request'&&s.traceId!==remoteTrace&&!s.parentSpanId),'Invalid remote context must start a new root trace');
|
||||
});
|
||||
@@ -11,3 +11,5 @@ test('security file facts carry exact bytes and detected type without arbitrary
|
||||
e.metadata.file={bytes:123,detectedType:'password=secret',sha256:'a'.repeat(64)};
|
||||
assert.equal(toECS(e).file,undefined);assert.equal(toCEF(e).includes('password=secret'),false);
|
||||
});
|
||||
|
||||
test('OLE Office magic facts remain representable in durable SIEM records',()=>{const file=facts.transferFacts({bytes:512,detectedType:'ole',sha256:'b'.repeat(64)});assert.equal(toECS({id:9,time:new Date().toISOString(),kind:'download.scan',metadata:{file}}).file.type,'ole');});
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {once} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {acquireUploadLease}=require('../apps/browser/upload-lease.cjs');
|
||||
const options={pythonPath:path.resolve('.runtime/windows/python/python.exe'),helperPath:path.resolve('apps/inspector/upload_lease.py')};
|
||||
test('Windows immutable upload snapshot locks write, rename and hard-link overwrite until explicit release',{skip:process.platform!=='win32'},async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-upload-lease-'));
|
||||
const file=path.join(dir,'safe.bin'),alias=path.join(dir,'alias.bin');
|
||||
fs.writeFileSync(file,Buffer.from('synthetic-upload'));fs.linkSync(file,alias);
|
||||
let lease;
|
||||
try{
|
||||
lease=await acquireUploadLease({...options,paths:[file],maxBytes:4096});
|
||||
assert.equal(lease.files[0].bytes.toString(),'synthetic-upload');assert.equal(lease.isAlive(),true);
|
||||
assert.throws(()=>fs.writeFileSync(file,'changed'));assert.throws(()=>fs.writeFileSync(alias,'changed'));assert.throws(()=>fs.renameSync(file,path.join(dir,'moved.bin')));
|
||||
await lease.release();lease=null;fs.writeFileSync(file,'released');assert.equal(fs.readFileSync(alias,'utf8'),'released');
|
||||
}finally{if(lease)await lease.release();assert.ok(path.resolve(dir).startsWith(path.resolve(os.tmpdir())+path.sep+'safetynet-upload-lease-'));fs.rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
test('broker refuses UNC, device, ADS and noncanonical upload paths before spawn',async()=>{
|
||||
for(const value of ['\\\\server\\share\\a','\\\\?\\C:\\file','C:\\file:stream','C:\\folder\\..\\file'])await assert.rejects(acquireUploadLease({...options,paths:[value]}),{code:'upload-lease-refused'});
|
||||
});
|
||||
test('parent handle copies survive helper death and trusted explicit cleanup releases them',{skip:process.platform!=='win32'},async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-upload-lease-death-')),file=path.join(dir,'safe.bin');fs.writeFileSync(file,'snapshot');
|
||||
const start=()=>spawn(options.pythonPath,['-I','-B',options.helperPath],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR}});
|
||||
const child=start();let text='';
|
||||
const ready=new Promise((resolve,reject)=>{child.stdout.on('data',c=>{text+=c;if(text.includes('\n'))resolve(JSON.parse(text.split('\n')[0]));});child.on('error',reject);});
|
||||
child.stdin.write(JSON.stringify({version:'safetynet-upload-lease-v1',paths:[file],maxBytes:1024,parentPid:process.pid})+'\n');
|
||||
const receipt=await ready;assert.equal(receipt.ok,true);const exited=once(child,'exit');child.kill();await exited;
|
||||
assert.throws(()=>fs.writeFileSync(file,'changed'));assert.throws(()=>fs.renameSync(file,path.join(dir,'moved.bin')));
|
||||
const cleanup=start();let output='';cleanup.stdout.on('data',c=>output+=c);const cleaned=once(cleanup,'exit');cleanup.stdin.end(JSON.stringify({version:'safetynet-upload-lease-v1',parentPid:process.pid,closeHandles:receipt.handles})+'\n');
|
||||
const [code]=await cleaned;assert.equal(code,0);assert.equal(JSON.parse(output).released,true);fs.writeFileSync(file,'released');
|
||||
assert.ok(path.resolve(dir).startsWith(path.resolve(os.tmpdir())+path.sep+'safetynet-upload-lease-death-'));fs.rmSync(dir,{recursive:true,force:true});
|
||||
});
|
||||
test('existing writable memory mapping is refused even after creator file descriptor closes',{skip:process.platform!=='win32'},async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-upload-lease-mapping-')),file=path.join(dir,'safe.bin');fs.writeFileSync(file,'snapshot');
|
||||
const code="import sys,json,mmap\np=json.loads(sys.stdin.readline())\nf=open(p,'r+b')\nm=mmap.mmap(f.fileno(),0,access=mmap.ACCESS_WRITE)\nf.close()\nprint('ready',flush=True)\nsys.stdin.readline()\nm.close()";
|
||||
const mapper=spawn(options.pythonPath,['-I','-B','-c',code],{windowsHide:true,stdio:['pipe','pipe','pipe']});
|
||||
const ready=once(mapper.stdout,'data');mapper.stdin.write(JSON.stringify(file)+'\n');await ready;
|
||||
try{await assert.rejects(acquireUploadLease({...options,paths:[file],maxBytes:1024}),{code:'upload-lease-refused'});}finally{const exited=once(mapper,'exit');mapper.stdin.end('release\n');await exited;assert.ok(path.resolve(dir).startsWith(path.resolve(os.tmpdir())+path.sep+'safetynet-upload-lease-mapping-'));fs.rmSync(dir,{recursive:true,force:true});}
|
||||
const lease=await acquireUploadLease({...options,paths:[path.resolve('README.md')],maxBytes:1048576});await lease.release();
|
||||
});
|
||||
test('junction ancestor, oversize and preexisting writable descriptor are refused without disabling later valid leases',{skip:process.platform!=='win32'},async()=>{
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-upload-lease-negative-')),target=path.join(dir,'target'),junction=path.join(dir,'junction');fs.mkdirSync(target);const file=path.join(target,'safe.bin');fs.writeFileSync(file,'snapshot');fs.symlinkSync(target,junction,'junction');
|
||||
try{
|
||||
await assert.rejects(acquireUploadLease({...options,paths:[path.join(junction,'safe.bin')],maxBytes:1024}),{code:'upload-lease-refused'});
|
||||
await assert.rejects(acquireUploadLease({...options,paths:[file],maxBytes:1}),{code:'upload-lease-refused'});
|
||||
const fd=fs.openSync(file,'r+');try{await assert.rejects(acquireUploadLease({...options,paths:[file],maxBytes:1024}),{code:'upload-lease-refused'});}finally{fs.closeSync(fd);}
|
||||
const lease=await acquireUploadLease({...options,paths:[file],maxBytes:1024});await lease.release();await assert.rejects(lease.release(),{code:'upload-lease-refused'});
|
||||
}finally{fs.unlinkSync(junction);assert.ok(path.resolve(dir).startsWith(path.resolve(os.tmpdir())+path.sep+'safetynet-upload-lease-negative-'));fs.rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
Reference in new issue
Block a user