Harden Windows DLP enforcement and Electron runtime packaging
This commit is contained in:
1 parent
e7d778aeac
commit
8a253eda89
120 files changed
+3569
-135
No files matched your search
@@ -26,3 +26,5 @@ test('controlled credential submission and agent preparation start off and round
|
||||
});
|
||||
|
||||
test('visual export remains separate unchecked page-only opt-in in central and individual grants',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);assert.ok(scopes.includes('screenshot'));const tag=html.match(/<input[^>]*id="agent-scope-screenshot"[^>]*>/)[0];assert.equal(/\bchecked\b/.test(tag),false);for(const text of ['Export inspected foreground page viewport','does not authorize desktop, other windows or browser-interface capture','Sensitive-read permission does not bypass visual inspection'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('for(const scope of agentScopes)'));assert.ok(editor.includes('check.checked=grant.scopes.includes(scope)'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));assert.ok(editor.includes('never the desktop or browser interface'));});
|
||||
|
||||
test('attributed worker controls default off and profile editor cannot widen baseline',()=>{for(const id of ['agent-background-workers','agent-scope-network:background'])assert.equal(/\bchecked\b/.test(html.match(new RegExp(`<input[^>]*id="${id}"[^>]*>`))[0]),false);assert.ok(html.includes('not an RPC action'));assert.ok(html.includes('human/default/shared/legacy workers'));const select=html.match(/<select id="profile-agentBackgroundWorkers">([\s\S]*?)<\/select>/)[1];assert.ok(select.includes('value="inherit"'));assert.ok(select.includes('value="deny"'));assert.ok(!select.includes('value="allow"'));const editor=readFileSync(new URL('../apps/server/public/profile-admin.js',import.meta.url),'utf8');assert.ok(editor.includes("'agentBackgroundWorkers'"));});
|
||||
@@ -3,6 +3,15 @@ import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import audit from '../packages/core/agent-audit.cjs';
|
||||
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||
|
||||
test('worker audit retains immutable principal generation without inventing a foreground session',()=>{
|
||||
const facts=audit.agentAuditFacts({action:'network:background',outcome:'completed',principalId:'worker-agent',credentialGeneration:3});
|
||||
assert.equal(facts.scope,'network:background');assert.equal(facts.sessionId,undefined);
|
||||
assert.deepEqual(audit.agentFactsForEvent('agent.action',facts),facts);
|
||||
assert.equal(audit.agentFactsForEvent('agent.denied',facts),null);
|
||||
assert.throws(()=>audit.agentAuditFacts({...facts,credentialGeneration:undefined}));
|
||||
for(const key of ['body','cookies','authorization','initiator','url'])assert.throws(()=>audit.agentAuditFacts({...facts,[key]:'PRIVATE_WORKER_DATA'}));
|
||||
});
|
||||
test('agent audit binds every fixed action to its scope and rejects secret-bearing extras',()=>{
|
||||
for(const [action,scope] of Object.entries(audit.AGENT_SCOPES)){
|
||||
const outcome=action==='agent-request'?'denied':'completed';
|
||||
|
||||
@@ -147,6 +147,7 @@ test('bookmark mutation schema requires exact revision and rejects extra capabil
|
||||
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);
|
||||
// eslint-disable-next-line no-unused-vars -- The test deliberately removes the optional folder field from the request.
|
||||
const {folder,...withoutFolder}=args.bookmark;
|
||||
assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200);
|
||||
assert.equal(captured.bookmark.folder,'');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{createAgentSessionOwnership}=require('../apps/browser/agent-session-ownership.cjs');
|
||||
const owner={deviceId:'00000000-0000-4000-8000-000000000001',principalId:'agent-one',generation:1};
|
||||
function fixture(){const created=[],installed=[],disposed=[],quarantined=[],live=new Map();let background=false;const deps={createSession:async(partition,options)=>{const session={partition,options,cookies:new Map()};created.push(session);return session;},installGuards:async(session,who,guard)=>{installed.push({session,who,guard});},isLive:(who,options)=>live.get(who.principalId)!==false&&(!options.background||background),quarantineSession:s=>quarantined.push(s),disposeSession:async s=>disposed.push(s)};return {created,installed,disposed,quarantined,live,deps,allowBackground:()=>background=true,make:()=>createAgentSessionOwnership(deps)};}
|
||||
test('native partitions and cookies are isolated immutable owners and installed before use',async()=>{const f=fixture(),r=f.make(),a=await r.acquire(owner),b=await r.acquire({...owner,principalId:'agent-two'});assert.notEqual(a,b);assert.notEqual(a.partition,b.partition);assert.equal(a.partition.startsWith('persist:'),false);assert.deepEqual(a.options,{cache:false});a.cookies.set('session','A');assert.equal(b.cookies.has('session'),false);assert.equal(f.installed.length,2);assert.equal(Object.isFrozen(r.getOwner(a)),true);assert.deepEqual(r.getOwner(a),owner);assert.equal(await r.acquire(owner),a);await r.close();});
|
||||
test('guard installation must finish before native session is acquired',async()=>{const f=fixture();let finish;f.deps.installGuards=()=>new Promise(resolve=>finish=resolve);const r=f.make();let released=false;const promise=r.acquire(owner).then(s=>{released=true;return s;});await new Promise(resolve=>setImmediate(resolve));assert.equal(released,false);finish();await promise;assert.equal(released,true);await r.close();});
|
||||
test('worker background requests require separate live authority and copied guard options',async()=>{const f=fixture(),r=f.make(),session=await r.acquire(owner);assert.throws(()=>r.guard(session,{url:'https://example.com/',background:true}));f.allowBackground();const options={url:'https://example.com/',background:true},guard=r.guard(session,options);options.background=false;f.live.set(owner.principalId,false);assert.throws(guard.assertCurrent);await r.close();});
|
||||
test('revoked generation never reuses partition or cookie state, newer generation gets fresh native session',async()=>{const f=fixture(),r=f.make(),old=await r.acquire(owner),guard=r.guard(old);old.cookies.set('login','private');await r.revoke(owner);assert.throws(guard.assertCurrent);await assert.rejects(r.acquire(owner));const next=await r.acquire({...owner,generation:2});assert.notEqual(next.partition,old.partition);assert.equal(next.cookies.size,0);assert.deepEqual(f.quarantined,[old]);assert.ok(f.disposed.includes(old));await r.close();});
|
||||
test('revocation during guard install quarantines and never releases partially installed session',async()=>{const f=fixture();let finish;f.deps.installGuards=()=>new Promise(resolve=>finish=resolve);const r=f.make(),acquire=r.acquire(owner);await new Promise(resolve=>setImmediate(resolve));const revoke=r.revoke(owner);finish();await assert.rejects(acquire);await revoke;assert.equal(f.disposed.length,1);await assert.rejects(r.acquire(owner));await r.close();});
|
||||
test('native session object cannot be retagged or borrowed from another principal',async()=>{const f=fixture(),shared={};f.deps.createSession=async()=>shared;const r=f.make();await r.acquire(owner);await assert.rejects(r.acquire({...owner,principalId:'other'}));assert.deepEqual(r.getOwner(shared),owner);await r.close();});
|
||||
test('failed install, aborted acquisition and capacity preserve fail-closed ownership',async()=>{const f=fixture();f.deps.installGuards=async()=>{throw Error('Guards incomplete');};const r=f.make();await assert.rejects(r.acquire(owner));assert.equal(f.disposed.length,1);await assert.rejects(r.acquire(owner));await r.close();const g=fixture();g.deps.maxSessions=1;const s=g.make();await s.acquire(owner);await assert.rejects(s.acquire({...owner,principalId:'other'}));await assert.rejects(s.acquire({...owner,principalId:'third'},{signal:AbortSignal.abort()}));await s.close();});
|
||||
test('failed native cleanup retains capacity and disposal flag until explicit retry succeeds',async()=>{const f=fixture();f.deps.maxSessions=1;let failCleanup=true,calls=0;f.deps.disposeSession=async s=>{calls++;if(failCleanup)throw Error('Native cleanup failed');f.disposed.push(s);};const r=f.make(),native=await r.acquire(owner);await assert.rejects(r.revoke(owner));const rows=r.enumerateEntries();assert.equal(rows.length,1);assert.equal(rows[0].active,false);assert.equal(rows[0].disposed,false);assert.equal(rows[0].session,native);await assert.rejects(r.acquire({...owner,generation:2}));failCleanup=false;const result=await r.sweep();assert.equal(result[0].status,'fulfilled');assert.equal(r.enumerateEntries().length,0);assert.equal(calls,2);await r.acquire({...owner,generation:2});await r.close();});
|
||||
test('policy sweep invalidates live guards synchronously and readonly inventory never exposes mutable owner',async()=>{const f=fixture(),r=f.make(),native=await r.acquire(owner),guard=r.guard(native);const entries=r.enumerateEntries(),sessions=r.liveSessions();assert.equal(Object.isFrozen(entries),true);assert.equal(Object.isFrozen(entries[0]),true);assert.equal(Object.isFrozen(entries[0].owner),true);assert.equal(Object.isFrozen(sessions),true);assert.deepEqual(sessions,[native]);f.live.set(owner.principalId,false);const sweeping=r.sweep();assert.throws(guard.assertCurrent);assert.equal(r.liveSessions().length,0);await sweeping;assert.equal(r.enumerateEntries().length,0);await r.close();});
|
||||
test('network release guard refuses requests before guard installation completes',async()=>{const f=fixture();let deniedEarly=false;f.deps.installGuards=async(_session,_owner,guard)=>{assert.doesNotThrow(()=>guard({phase:'install'}));assert.throws(()=>guard({phase:'network'}));deniedEarly=true;};const r=f.make(),native=await r.acquire(owner);assert.equal(deniedEarly,true);r.guard(native).assertCurrent();await r.close();});
|
||||
test('repeated close retries failed cleanup without reopening revoked sessions',async()=>{const f=fixture();let failCleanup=true;f.deps.disposeSession=async()=>{if(failCleanup)throw Error('Failure');};const r=f.make();await r.acquire(owner);await r.close();assert.equal(r.enumerateEntries().length,1);assert.equal(r.enumerateEntries()[0].disposed,false);failCleanup=false;await r.close();assert.equal(r.enumerateEntries().length,0);await assert.rejects(r.acquire({...owner,generation:2}));});
|
||||
@@ -0,0 +1,33 @@
|
||||
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 {randomUUID} from 'node:crypto';
|
||||
import {openStore} from '../apps/server/store.mjs';
|
||||
import {runApprovedAI} from '../apps/server/ai-gateway.mjs';
|
||||
|
||||
for(const change of ['revoke','same-version-policy','output-revoke','client-abort'])test(`gateway suppresses release after ${change} during actual ICAP scan`,async t=>{
|
||||
const directory=mkdtempSync(join(tmpdir(),'safetynet-ai-race-')),store=openStore(directory),deviceId=randomUUID();
|
||||
store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run(deviceId,'Fixture','win32','Fixture',randomUUID());
|
||||
let deliveries=0,scans=0;const sockets=new Set(),controller=new AbortController();
|
||||
const provider=createServer((req,res)=>{deliveries++;req.resume();res.end(JSON.stringify({choices:[{message:{content:'Public answer'}}]}));}).listen(0,'127.0.0.1');
|
||||
await new Promise(r=>provider.once('listening',r));
|
||||
const scanner=net.createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.once('close',()=>sockets.delete(socket));let bytes=Buffer.alloc(0),done=false;
|
||||
socket.on('data',chunk=>{bytes=Buffer.concat([bytes,chunk]);if(done||!bytes.subarray(-5).equals(Buffer.from('0\r\n\r\n')))return;done=true;
|
||||
if(bytes.subarray(0,7).toString()==='OPTIONS'){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');return;}
|
||||
scans++;
|
||||
if(change==='client-abort')controller.abort();
|
||||
if(change==='revoke'||change==='output-revoke'&&scans===2)store.db.prepare('UPDATE devices SET revoked=1 WHERE id=?').run(deviceId);
|
||||
else if(change==='same-version-policy'){const p=store.get('policy');p.ai.enabled=false;store.put('policy',p);}
|
||||
socket.end('ICAP/1.0 204 No Content\r\nISTag: "fixture"\r\n\r\n');
|
||||
});
|
||||
}).listen(0,'127.0.0.1');await new Promise(r=>scanner.once('listening',r));
|
||||
t.after(async()=>{for(const socket of sockets)socket.destroy();provider.closeAllConnections();await Promise.all([provider,scanner].map(server=>new Promise(r=>server.close(r))));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||
const p=store.get('policy');p.ai={...p.ai,enabled:true,trustMode:'local',model:'fixture'};p.enterprise.icap={...p.enterprise.icap,enabled:true,url:`icap://127.0.0.1:${scanner.address().port}/scan`,timeoutMs:1000};store.put('policy',p);
|
||||
const policy=store.effectivePolicy(deviceId),config={aiBaseUrl:`http://127.0.0.1:${provider.address().port}`,aiModel:'fixture',aiEndpointId:'default'};
|
||||
await assert.rejects(runApprovedAI({store,config,deviceId,policy,prompt:'Public fixture prompt',signal:controller.signal}),error=>error.status===(change==='same-version-policy'?409:403)&&error.ruleId==='ai-authorization-changed');
|
||||
assert.equal(deliveries,change==='output-revoke'?1:0);
|
||||
});
|
||||
@@ -6,7 +6,6 @@ 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'});
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
|
||||
@@ -59,3 +59,15 @@ test('clipboard completion metadata follows actual commit and native write, neve
|
||||
events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0);
|
||||
options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0);
|
||||
});
|
||||
|
||||
test('paste rereads external clipboard per gesture and never swaps approved bytes during asynchronous scan',async()=>{
|
||||
const frame={},wc={mainFrame:frame,focusedFrame:frame};let clipboard='First public clipboard',context={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc};
|
||||
const scanned=[],inserted=[],events=[];let waiting=deferred(),started=deferred();
|
||||
const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:''}),validate:async()=>({completed:true}),commit:async args=>{inserted.push(args.text);return {completed:true};},discard:async()=>{},readClipboard:async()=>clipboard,writeClipboard:async()=>{},inspect:async bytes=>{scanned.push(bytes.toString());started.resolve();return waiting.promise;},audit:async(kind,ruleId,facts)=>events.push({kind,ruleId,facts})});
|
||||
const first=guard.perform('paste',wc);await started.promise;clipboard='Secret Roadmap changed by external app';waiting.resolve({decision:'allow'});assert.equal((await first).completed,true);assert.deepEqual(inserted,['First public clipboard']);
|
||||
waiting=deferred();started=deferred();const second=guard.perform('paste',wc);await started.promise;assert.equal(scanned.at(-1),clipboard);waiting.resolve({decision:'block',ruleId:'company-secret'});assert.equal((await second).completed,false);assert.deepEqual(inserted,['First public clipboard']);
|
||||
clipboard='Third public clipboard';waiting=deferred();started=deferred();const third=guard.perform('paste',wc);await started.promise;context={...context,policy:{...context.policy,version:context.policy.version+1}};waiting.resolve({decision:'allow'});assert.equal((await third).completed,false);assert.deepEqual(inserted,['First public clipboard']);
|
||||
assert.ok(events.some(e=>e.kind==='clipboard.paste'&&e.facts.action==='paste'&&e.facts.sessionId===context.tabId&&e.facts.outcome==='authorized'));
|
||||
assert.ok(events.some(e=>e.kind==='clipboard.block'&&e.ruleId==='company-secret'));
|
||||
assert.equal(JSON.stringify(events).includes('clipboard changed by external'),false);assert.equal(JSON.stringify(events).includes('Secret Roadmap'),false);assert.equal(JSON.stringify(events).includes('First public clipboard'),false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const {app,BrowserWindow}=require('electron');
|
||||
const assert=require('node:assert/strict');
|
||||
const {clipboardScript}=require('../apps/browser/clipboard-dom.cjs');
|
||||
const {createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs');
|
||||
app.disableHardwareAcceleration();app.enableSandbox();
|
||||
app.whenReady().then(async()=>{
|
||||
const {defaultPolicy}=await import('../packages/core/policy.mjs');const {inspectClipboard}=await import('../packages/core/clipboard.mjs');
|
||||
const win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}}),wc=win.webContents;
|
||||
await wc.loadURL('data:text/html,<div id="editor" contenteditable="true" role="textbox"><p>Public draft</p></div><input id="other">');
|
||||
const url=wc.getURL(),exec=code=>wc.executeJavaScriptInIsolatedWorld(999,[{code}],false);
|
||||
const focus=async()=>exec("(()=>{const e=document.getElementById('editor');e.focus();const r=document.createRange();r.selectNodeContents(e);r.collapse(false);const s=getSelection();s.removeAllRanges();s.addRange(r);})()");
|
||||
const dom=(mode,args)=>exec(clipboardScript(mode,{...args,url,limit:4096}));
|
||||
const policy=defaultPolicy();policy.dlp.rules=[{id:'secret',label:'Secret',kind:'literal',value:'Secret Roadmap',action:'block'}];let paste=' public <img src=x onerror=alert(1)>',writes=[];
|
||||
|
||||
// Hidden native contents can report no focused frame; exercise the DOM under
|
||||
// its real main frame, and use a context facade for native focus checks.
|
||||
const facade={mainFrame:wc.mainFrame,focusedFrame:wc.mainFrame};
|
||||
const args={url,token:'one',operation:'paste',limit:4096};
|
||||
await focus();await dom('stage',args);await dom('commit',{...args,text:paste});
|
||||
assert.equal(await exec("document.getElementById('editor').querySelector('img')===null"),true);
|
||||
assert.equal((await exec("document.getElementById('editor').textContent")).includes(paste),true);
|
||||
for(const change of ["document.getElementById('editor').append(document.createTextNode('changed'))","document.getElementById('other').focus()","getSelection().removeAllRanges()"]){await focus();await dom('stage',{...args,token:change});await exec(change);await assert.rejects(dom('commit',{...args,token:change,text:'should not insert'}));}
|
||||
await focus();await dom('stage',{...args,token:'sensitive'});await exec("document.getElementById('editor').setAttribute('data-sensitive','true')");await assert.rejects(dom('commit',{...args,token:'sensitive',text:'no'}));await exec("document.getElementById('editor').removeAttribute('data-sensitive')");
|
||||
// A real native DOM plus the production guard proves DLP runs before mutation.
|
||||
const guarded=createClipboardGuard({getContext:()=>({ready:true,policy,url,tabId:'editor',activation:1,navigationId:1,webContents:facade}),stage:args=>dom('stage',args),validate:args=>dom('validate',args),commit:args=>dom('commit',args),discard:args=>dom('discard',args),readClipboard:async()=>paste,writeClipboard:async text=>writes.push(text),inspect:async(bytes,p,operation)=>inspectClipboard(bytes,p,operation),audit:async()=>{}});
|
||||
paste='Secret Roadmap';await focus();const before=await exec("document.getElementById('editor').innerHTML");assert.equal((await guarded.perform('paste',facade)).completed,false);assert.equal(await exec("document.getElementById('editor').innerHTML"),before);
|
||||
paste='powershell -EncodedCommand SQBFAFgAIAAoAGkAdwByACAAaAB0AHQAcAApAA==';assert.equal((await guarded.perform('paste',facade)).completed,false);assert.equal(await exec("document.getElementById('editor').innerHTML"),before);
|
||||
paste=' benign AI prompt';assert.equal((await guarded.perform('paste',facade)).completed,true);assert.ok((await exec("document.getElementById('editor').textContent")).endsWith(paste));
|
||||
await exec("(()=>{const e=document.getElementById('editor');e.textContent='Public editor selection';e.focus();const r=document.createRange();r.selectNodeContents(e);const s=getSelection();s.removeAllRanges();s.addRange(r);})()");
|
||||
assert.equal((await guarded.perform('copy',facade)).completed,true);assert.equal(writes.at(-1),'Public editor selection');
|
||||
assert.equal((await guarded.perform('cut',facade)).completed,true);assert.equal(writes.at(-1),'Public editor selection');assert.equal(await exec("document.getElementById('editor').textContent"),'');
|
||||
await exec("document.getElementById('editor').setAttribute('aria-label','API secret')");await assert.rejects(dom('stage',{...args,token:'sensitive-stage'}));
|
||||
console.log('CONTENTEDITABLE_CLIPBOARD_PASS');win.destroy();app.quit();
|
||||
}).catch(error=>{console.error(error.stack);app.exit(1);});
|
||||
@@ -0,0 +1,12 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
test('Chromium contenteditable plain paste blocks secrets, HTML execution and stale editor snapshots', {timeout:25000},async()=>{
|
||||
const require=createRequire(import.meta.url),env={...process.env};delete env.ELECTRON_RUN_AS_NODE;
|
||||
const child=spawn(require('electron'),[fileURLToPath(new URL('./contenteditable-clipboard.fixture.cjs',import.meta.url))],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';
|
||||
child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);
|
||||
const timer=setTimeout(()=>child.kill(),20000);const code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);});clearTimeout(timer);
|
||||
assert.equal(code,0,output);assert.match(output,/CONTENTEDITABLE_CLIPBOARD_PASS/);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {ControlledSubmissionSchema} from '../packages/core/credential-policy.mjs';const {validateCredentialRuleDraft}=createRequire(import.meta.url)('../apps/server/public/controlled-login-admin.js');const row=()=>({id:'login',actionUrl:'https://login.example/signin',usernameField:'username',passwordField:'password'});
|
||||
test('typed login editor roundtrips exact canonical metadata accepted by central schema',()=>{const value=[row(),{...row(),id:'other',actionUrl:'https://other.example/post',usernameField:'user[email]'}];const result=validateCredentialRuleDraft(value);assert.deepEqual(ControlledSubmissionSchema.parse({enabled:true,forms:result}).forms,value);assert.notEqual(result[0],value[0]);});
|
||||
test('typed login validation rejects ambiguous destinations and secret-bearing extras without echoing values',()=>{for(const rules of [[{...row(),password:'PRIVATE_SECRET'}],[{...row(),actionUrl:'https://u:[email protected]/post'}],[{...row(),actionUrl:'http://login.example/'}],[{...row(),actionUrl:'https://login.example/post?code=PRIVATE_SECRET'}],[row(),{...row(),id:'other'}],[{...row(),usernameField:'password'}],Array.from({length:33},row)]){assert.throws(()=>validateCredentialRuleDraft(rules),error=>!error.message.includes('PRIVATE_SECRET'));assert.throws(()=>ControlledSubmissionSchema.parse({forms:rules}));}});
|
||||
|
||||
import vm from 'node:vm';import {readFileSync} from 'node:fs';
|
||||
test('typed rows preserve reviewed targets, enforce auditor read-only and edit draft through actual field events',()=>{class Element{constructor(){this.children=[];this.listeners={};this.value='';this.checked=false;this.disabled=false;this.dataset={};}append(...nodes){this.children.push(...nodes);}replaceChildren(...nodes){this.children=nodes;}addEventListener(type,fn){this.listeners[type]=fn;}}const nodes=new Map(),get=id=>{if(!nodes.has(id))nodes.set(id,new Element());return nodes.get(id);};const original={passwordManager:{enabled:true,controlledSubmission:{enabled:true,forms:[row()]}}};let revision=0;const ctx=vm.createContext({document:{createElement:()=>new Element()},$:get,URL,structuredClone,fill(p){get('vault-submit-forms').value=JSON.stringify(p.passwordManager.controlledSubmission.forms);},draft:()=>({passwordManager:{...original.passwordManager,controlledSubmission:{enabled:get('vault-controlled-submit').checked,forms:JSON.parse(get('vault-submit-forms').value)}}}),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null,updateDraftStatus(){revision++;}});vm.runInContext(readFileSync(new URL('../apps/server/public/controlled-login-admin.js',import.meta.url),'utf8'),ctx);ctx.fill(original);ctx.applyOperatorPermissions({permissions:['policy.read']});assert.equal(get('vault-rule-add').disabled,true);let box=get('vault-rule-editor').children[0];assert.equal(box.children[1].children.every(label=>label.children[0].disabled),true);ctx.applyOperatorPermissions({permissions:['policy.prepare']});box=get('vault-rule-editor').children[0];const action=box.children[1].children[1].children[0];action.value='https://login.example/new';action.listeners.input();assert.equal(JSON.parse(get('vault-submit-forms').value)[0].actionUrl,'https://login.example/new');assert.equal(original.passwordManager.controlledSubmission.forms[0].actionUrl,row().actionUrl);assert.ok(revision>0);get('vault-submit-forms').value='[{"unknown":"PRIVATE_STAGED"}]';get('vault-submit-forms').listeners.change();assert.equal(get('vault-rule-add').disabled,true);action.value='https://login.example/discard';action.listeners.input();assert.equal(get('vault-submit-forms').value,'[{"unknown":"PRIVATE_STAGED"}]');get('vault-submit-forms').value=JSON.stringify([row()]);get('vault-submit-forms').listeners.change();box=get('vault-rule-editor').children[0];get('vault-controlled-submit').checked=true;assert.equal(ctx.draft().passwordManager.controlledSubmission.forms[0].id,'login');box.children[2].listeners.click();assert.throws(()=>ctx.draft(),/at least one/);});
|
||||
@@ -22,6 +22,7 @@ test('central ICAP capability probe sends no content; gateway cannot bypass exte
|
||||
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 encoded=await api('/api/device/ai',{prompt:Buffer.from(marker).toString('base64')},device.token);assert.equal(encoded.status,403);assert.equal(upstreamRequests,0,'Decoded blocked content must never reach the provider');
|
||||
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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';import {execFileSync} from 'node:child_process';import {randomBytes} from 'node:crypto';import {assertPrivateEvidenceKey} from '../apps/server/evidence-key.mjs';import {normalizePrincipal,principalForGroups} from '../apps/server/admin-access.mjs';
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {join,resolve,sep} from 'node:path';import {tmpdir} from 'node:os';import {randomBytes} from 'node:crypto';import {assertPrivateEvidenceKey} from '../apps/server/evidence-key.mjs';import {normalizePrincipal,principalForGroups} from '../apps/server/admin-access.mjs';
|
||||
test('analyst identity grants evidence/triage but no policy or fleet administration',()=>{const p=normalizePrincipal({sub:'soc',role:'analyst'});assert.ok(p.permissions.includes('evidence.read'));for(const permission of ['policy.write','devices.manage','sessions.manage','integrations.manage'])assert.equal(p.permissions.includes(permission),false);assert.equal(principalForGroups({sub:'soc'},['soc-team'],{oidcAdminGroup:'admins',oidcAnalystGroup:'soc-team'}).role,'analyst');});
|
||||
// eslint-disable-next-line no-unsafe-finally -- Cleanup failure must fail closed rather than report successful protected cleanup.
|
||||
test('Windows evidence key rejects inherited shared read permissions',{skip:process.platform!=='win32'},t=>{const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-acl-')),file=join(dir,'evidence.key');try{writeFileSync(file,randomBytes(32));let rejected=false;try{assertPrivateEvidenceKey(file);}catch{rejected=true;}if(!rejected){t.skip('This Windows temp directory already supplies private inherited ACLs');return;}assert.equal(rejected,true);}finally{if(!resolve(dir).startsWith(resolve(tmpdir())+sep))throw Error('Unsafe cleanup');rmSync(dir,{recursive:true,force:true});}});
|
||||
@@ -8,7 +8,7 @@ async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir
|
||||
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}});
|
||||
|
||||
test('human observations bind exact event action and session without claiming transfer completion',async t=>{
|
||||
const {api,store,id}=await fixture(t),sessionId=randomUUID();
|
||||
const {api}=await fixture(t),sessionId=randomUUID();
|
||||
for(const action of ['click','submit']){
|
||||
const eventId=randomUUID(),payload={pageUrl:'https://intranet.example/',action,sessionId,outcome:'observed',captureSource:'human-observer',sensitiveField:false,target:{tag:'BUTTON'}};
|
||||
assert.equal((await api('/api/device/events','POST',{eventId,kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome:'observed'}})).status,200);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
const {inspectTextRepresentations}=createRequire(import.meta.url)('../apps/browser/inspect-text-representations.cjs');
|
||||
test('decoded representations reach semantic scanner separately and block before external release',async()=>{
|
||||
const policy=defaultPolicy();policy.dlp.semantic.enabled=true;let calls=0,external=0;
|
||||
const secret='Synthetic confidential paraphrase';
|
||||
const result=await inspectTextRepresentations({bytes:Buffer.from(Buffer.from(secret).toString('base64')),policy,isCurrent:()=>true,inspectLocal:async data=>{calls++;return data.toString()===secret?{decision:'block',ruleId:'semantic-secret'}:{decision:'allow',coverage:{complete:true}};},inspectEnterprise:async()=>{external++;return {decision:'allow',coverage:{complete:true}};}});
|
||||
assert.equal(result.ruleId,'semantic-secret');assert.equal(calls,2);assert.equal(external,1);
|
||||
});
|
||||
test('policy changes and incomplete scanner coverage fail closed',async()=>{
|
||||
const policy=defaultPolicy();policy.dlp.semantic.enabled=true;
|
||||
let current=true,external=0;
|
||||
const result=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>current,inspectLocal:async()=>{current=false;return {decision:'allow',coverage:{complete:true}};},inspectEnterprise:async()=>{external++;}});
|
||||
assert.equal(result.ruleId,'inspection-context-changed');assert.equal(external,0);
|
||||
const incomplete=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>true,inspectLocal:async()=>({decision:'allow'})});
|
||||
assert.equal(incomplete.ruleId,'inspection-incomplete');
|
||||
});
|
||||
|
||||
test('configured enterprise scanner cannot be silently omitted',async()=>{
|
||||
const policy=defaultPolicy();policy.enterprise.icap.enabled=true;
|
||||
const result=await inspectTextRepresentations({bytes:Buffer.from('Public text'),policy,isCurrent:()=>true});
|
||||
assert.equal(result.decision,'block');assert.equal(result.ruleId,'icap-unavailable');
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import http from 'node:http';import https from 'node:https';import net from 'node:net';import tls from 'node:tls';import {readFileSync} from 'node:fs';import {createRequire} from 'node:module';
|
||||
const {startLocalDlpRelay}=createRequire(import.meta.url)('../apps/browser/local-dlp-relay.cjs');
|
||||
async function listening(server,t){server.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections?.();await new Promise(r=>server.close(r));});return server.address().port;}
|
||||
function basic(relay){const c=relay.credentials();return 'Basic '+Buffer.from(c.username+':'+c.password).toString('base64');}
|
||||
function get(relay,target,auth=basic(relay)){return new Promise((resolve,reject)=>{const [host,port]=relay.proxyRules.split(':'),req=http.request({host,port,path:target,headers:{host:new URL(target).host,...(auth?{'proxy-authorization':auth}:{})}},res=>{let text='';res.on('data',b=>text+=b);res.on('end',()=>resolve({status:res.statusCode,text}));});req.on('error',reject);req.end();});}
|
||||
function connect(relay,target){return new Promise((resolve,reject)=>{const [host,port]=relay.proxyRules.split(':'),socket=net.connect({host,port});let bytes=Buffer.alloc(0);socket.on('error',reject);socket.on('data',function header(chunk){bytes=Buffer.concat([bytes,chunk]);if(bytes.indexOf('\r\n\r\n')<0)return;socket.removeListener('data',header);if(!bytes.toString().startsWith('HTTP/1.1 200')){socket.destroy();reject(Error('CONNECT refused'));return;}resolve(socket);});socket.once('connect',()=>socket.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\nProxy-Authorization: ${basic(relay)}\r\n\r\n`));});}
|
||||
async function fixture(t,overrides={}){const authority={version:1},addresses=[];const relay=await startLocalDlpRelay({authorize:async()=>authority,isCurrent:a=>a===authority,resolveUpstream:async()=> 'DIRECT',resolveAddress:async host=>{addresses.push(host);return '127.0.0.1';},...overrides});t.after(()=>relay.close());return {relay,authority,addresses};}
|
||||
test('private loopback relay authenticates capability and forwards approved HTTP without leaking proxy credentials',async t=>{
|
||||
let hits=0,headers;const port=await listening(http.createServer((req,res)=>{hits++;headers=req.headers;res.end('public receiver');}),t),f=await fixture(t),url=`http://fixture.invalid:${port}/public`;
|
||||
assert.equal((await get(f.relay,url,null)).status,407);assert.equal(hits,0);assert.equal((await get(f.relay,url,'Basic forged')).status,407);
|
||||
const result=await get(f.relay,url);assert.equal(result.status,200);assert.equal(result.text,'public receiver');assert.equal(hits,1);assert.equal(headers['proxy-authorization'],undefined);assert.deepEqual(f.addresses,['fixture.invalid']);assert.equal(f.relay.proxyBypassRules,'<-loopback>');
|
||||
});
|
||||
test('native CONNECT tunnel carries actual TLS application data and remains private',async t=>{
|
||||
const certificate=readFileSync('tests/fixtures/icap/cert.pem'),key=readFileSync('tests/fixtures/icap/key.pem');let hits=0;
|
||||
const port=await listening(https.createServer({cert:certificate,key},(_req,res)=>{hits++;res.end('public TLS receiver');}),t),f=await fixture(t),socket=await connect(f.relay,`127.0.0.1:${port}`);
|
||||
const secure=tls.connect({socket,ca:certificate,servername:'localhost'});let response='';await new Promise((resolve,reject)=>{secure.on('error',reject);secure.once('secureConnect',()=>secure.write('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'));secure.on('data',b=>response+=b);secure.once('end',resolve);});assert.match(response,/public TLS receiver/);assert.equal(hits,1);
|
||||
});
|
||||
test('PAC selected upstream proxy is preserved, no target DNS is performed and unsupported SOCKS never falls back',async t=>{
|
||||
let hits=0;const proxyPort=await listening(http.createServer((req,res)=>{hits++;assert.equal(req.url,'http://enterprise.invalid:8080/public');assert.equal(req.headers['proxy-authorization'],'Basic '+Buffer.from('corporate:user-password').toString('base64'));res.end('enterprise forwarded');}),t);
|
||||
const f=await fixture(t,{resolveUpstream:async()=>`PROXY proxy.enterprise.invalid:${proxyPort}; DIRECT`,credentialsForProxy:async()=>({username:'corporate',password:'user-password'})});assert.equal((await get(f.relay,'http://enterprise.invalid:8080/public')).text,'enterprise forwarded');assert.deepEqual(f.addresses,['proxy.enterprise.invalid']);assert.equal(hits,1);
|
||||
const unsupported=await fixture(t,{resolveUpstream:async()=> 'SOCKS5 proxy.enterprise.invalid:1080; DIRECT'});assert.equal((await get(unsupported.relay,'http://enterprise.invalid:8080/public')).status,502);assert.equal(unsupported.addresses.length,0);
|
||||
});
|
||||
test('policy invalidation during async resolution denies transmission and existing tunnels are closed',async t=>{
|
||||
let release,started;const begun=new Promise(r=>started=r),waiting=new Promise(r=>release=r);let hits=0;
|
||||
const port=await listening(http.createServer((_req,res)=>{hits++;res.end('should not release');}),t),f=await fixture(t,{resolveUpstream:async()=>{started();return waiting;}});
|
||||
const pending=get(f.relay,`http://fixture.invalid:${port}/`);await begun;f.relay.invalidate();release('DIRECT');await assert.rejects(pending);assert.equal(hits,0);
|
||||
const sockets=new Set(),echo=net.createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.on('data',data=>socket.write(data));socket.once('close',()=>sockets.delete(socket));}),echoPort=await listening(echo,t),g=await fixture(t),socket=await connect(g.relay,`fixture.invalid:${echoPort}`);await new Promise(resolve=>{socket.once('data',data=>{assert.equal(data.toString(),'public echo');resolve();});socket.write('public echo');});const ended=new Promise(r=>socket.once('close',r));g.relay.invalidate();await ended;assert.equal(g.relay.status().connections,0);for(const s of sockets)s.destroy();
|
||||
});
|
||||
test('resolver authority errors and nonnumeric addresses fail closed before target connection',async t=>{
|
||||
const f=await fixture(t,{resolveAddress:async()=> 'unapproved.example'});assert.equal((await get(f.relay,'http://target.invalid:8080/')).status,502);
|
||||
const g=await fixture(t,{isCurrent:()=>{throw Error('Policy unavailable');}});assert.equal((await get(g.relay,'http://target.invalid:8080/')).status,502);assert.equal(g.addresses.length,0);
|
||||
});
|
||||
test('upstream proxy challenge does not impersonate local relay authentication and resolution hangs expire',async t=>{
|
||||
const port=await listening(http.createServer((_req,res)=>{res.writeHead(407,{'proxy-authenticate':'Basic realm="untrusted upstream"'});res.end('do not expose authentication data');}),t);
|
||||
const f=await fixture(t,{resolveUpstream:async()=>`PROXY proxy.invalid:${port}`});const result=await get(f.relay,'http://target.invalid:8080/');assert.equal(result.status,502);assert.equal(result.text,'');
|
||||
const g=await fixture(t,{timeoutMs:100,resolveUpstream:async()=>new Promise(()=>{})});await assert.rejects(get(g.relay,'http://target.invalid:8080/'));assert.equal(g.addresses.length,0);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8'),start=source.indexOf('readClipboard:async limit=>')+'readClipboard:'.length,end=source.indexOf('writeClipboard:',start);const code=source.slice(start,end).trim().replace(/,$/,'');
|
||||
function fixture(types,text='PUBLIC',size=6){const calls=[],blob={size,text:async()=>text},item={types,getType:async type=>{calls.push(type);if(type!=='text/plain')throw Error('HTML must not be read');return blob;}},read=vm.runInNewContext('('+code+')',{clipboard:{read:async()=>[item]}});return {read,calls};}
|
||||
test('rich HTML clipboard exports only independently bounded inert plain text',async()=>{const f=fixture(['text/plain','text/html','electron application/osclipboard;format="HTML Format"']);assert.equal(await f.read(1024),'PUBLIC');assert.deepEqual(f.calls,['text/plain']);});
|
||||
test('files images and unsupported rich clipboard formats remain denied',async()=>{for(const types of [['text/html'],['text/plain','image/png'],['text/plain','electron application/osclipboard;format="CF_HDROP"'],['text/plain','application/rtf']]){const f=fixture(types);await assert.rejects(f.read(1024));assert.deepEqual(f.calls,[]);}});
|
||||
test('plain representation size is bounded before it is read',async()=>{const f=fixture(['text/plain','text/html'],'PUBLIC',2048);await assert.rejects(f.read(1024));assert.deepEqual(f.calls,['text/plain']);});
|
||||
test('WebSocket handshake is denied even when an old central policy enables the unsupported flag',()=>{assert.match(source,/if\(details\.resourceType==='webSocket'\)\{audit\('dlp\.block','websocket-disabled'/);assert.doesNotMatch(source,/details\.resourceType==='webSocket'&&!policy\.browser\.allowWebSockets/);});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import {createRequire} from 'node:module';import vm from 'node:vm';import * as core from '../packages/core/policy.mjs';const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');const wrapper=source.slice(source.indexOf('function inspectOutgoingText('),source.indexOf('function responsePolicyHeaders(')),headers=source.slice(source.indexOf('function responsePolicyHeaders('),source.indexOf('function hooks(){')),gate=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequest({urls:['<all_urls>']},(details,callback)=>{"),source.indexOf(" browserSession.webRequest.onBeforeSendHeaders({urls:['<all_urls>']},(details,callback)=>{"));
|
||||
function fixture(){const policy=core.defaultPolicy();policy.security.visualPhishing.enabled=false;policy.dlp.semantic.enabled=true;const calls=[],events=[];let listener,current=true;const contents={getURL:()=> 'https://example.com/app',isDestroyed:()=>false},tab={id:'tab1',guarded:true,security:{},view:{webContents:contents}},context=vm.createContext({require:id=>id.startsWith('./')?require('../apps/browser/'+id.slice(2)):require(id.startsWith('../../')?'../'+id.slice(6):id),Buffer,URL,Date,setTimeout,clearTimeout,core,policy,ready:true,localInspector:{inspect:async data=>{const value=data.toString();calls.push({kind:'local',value});return value==='Synthetic confidential paraphrase'?{decision:'block',ruleId:'semantic-secret'}:{decision:'allow',coverage:{complete:true}};}},scanEnterprise:async data=>{calls.push({kind:'icap',value:data.toString()});return {decision:'allow',coverage:{complete:true}};},diagnosticSpan:(_name,_facts,fn)=>fn(),requestDiagnostic:(_details,_facts,fn)=>fn(),browserSession:{webRequest:{onBeforeRequest:(_filter,fn)=>listener=fn}},developerAssetRequestAllowed:()=>false,tabs:{fromContents:()=>tab,all:()=>[tab]},tabContext:()=>({webContents:contents}),currentTabContext:()=>current,agentRequestOwnerAllowed:()=>true,actorDestinationAllowed:()=>true,credentialBroker:null,recentRequests:new Map(),dnsGate:async()=>calls.push({kind:'dns'}),audit:async(kind,ruleId)=>events.push({kind,ruleId}),checkVisual:async()=>({decision:'allow'}),icapRequired:()=>true,emit:()=>{},message:'',uploadLeaseFatal:false,diagnosticCorrelation:()=>({}),credentialRequestCorrelations:new Map()});vm.runInContext(wrapper+gate,context);const run=details=>new Promise((resolve,reject)=>{const deadline=setTimeout(()=>reject(Error('Gate timeout')),3000);listener({...details,webContentsId:1,resourceType:details.resourceType||'xhr'},result=>{clearTimeout(deadline);resolve(result);});});return {context,policy,calls,events,run,invalidate:()=>current=false};}
|
||||
test('actual main full URL scans decoded semantic and ICAP representations before DNS lookup',async()=>{const f=fixture(),encoded=Buffer.from('Synthetic confidential paraphrase').toString('base64'),r=await f.run({id:1,url:'https://example.com/?prompt='+encoded,method:'GET'});assert.equal(r.cancel,true);assert.ok(f.calls.some(c=>c.value==='Synthetic confidential paraphrase'));assert.equal(f.calls.some(c=>c.kind==='dns'),false);assert.ok(f.events.some(e=>e.ruleId==='semantic-secret'));});
|
||||
test('actual main benign URL and encoded text body pass normal inspection while decoded secrets do not',async()=>{for(const secret of [false,true]){const f=fixture(),text=secret?'Synthetic confidential paraphrase':'PUBLIC_TEXT',bytes=Buffer.from(JSON.stringify({prompt:Buffer.from(text).toString('base64')})),result=await f.run({id:2,url:'https://example.com/upload',method:'POST',uploadData:[{bytes}]});assert.equal(result.cancel,secret);assert.ok(f.calls.some(c=>c.value===text));assert.ok(f.calls.some(c=>c.kind==='dns'));if(!secret)assert.ok(f.events.some(e=>e.kind==='upload.authorized'));}});
|
||||
test('actual main URL scan policy change prevents DNS and network release',async()=>{const f=fixture();f.context.scanEnterprise=async()=>{f.invalidate();return {decision:'allow',coverage:{complete:true}};};assert.equal((await f.run({id:1,url:'https://example.com/public',method:'GET'})).cancel,true);assert.equal(f.calls.some(c=>c.kind==='dns'),false);});
|
||||
test('native connection header retains existing site restrictions and forces WebRTC block on the first list',()=>{const context=vm.createContext({});vm.runInContext(headers,context);const input={'Content-Security-Policy':["default-src 'self'"],'connection-allowlist':['("https://restricted.example"); redirects=block; webrtc=allow, ("https://ignored.example"); webrtc=allow']},result=context.responsePolicyHeaders(input);assert.equal(result['Content-Security-Policy'],input['Content-Security-Policy']);assert.match(result['Connection-Allowlist'][0],/^\("https:\/\/restricted\.example"\); redirects=block; webrtc=allow; webrtc=block,/);assert.equal(input['connection-allowlist'][0].includes('; webrtc=block'),false);assert.match(context.responsePolicyHeaders({})['Connection-Allowlist'][0],/webrtc=block$/);});
|
||||
|
||||
test('header parser preserves quoted commas and parameters without weakening site endpoint restrictions',()=>{const context=vm.createContext({});vm.runInContext(headers,context);const original='("https://restricted.example/a,b?q=(x)"); note=";webrtc=allow"; webrtc=allow, ("https://ignored.example")';const result=context.responsePolicyHeaders({'Connection-Allowlist':[original]})['Connection-Allowlist'][0];assert.equal(result,'("https://restricted.example/a,b?q=(x)"); note=";webrtc=allow"; webrtc=allow; webrtc=block, ("https://ignored.example")');assert.throws(()=>context.responsePolicyHeaders({'Connection-Allowlist':['("unterminated)']}));});
|
||||
test('registered secret hostname is blocked before any explicit DNS resolution',async()=>{const f=fixture();f.policy.dlp.rules=[{id:'hostname-secret',kind:'literal',value:'synthetic-company-secret'}];assert.equal((await f.run({id:3,url:'https://synthetic-company-secret.example/path',method:'GET'})).cancel,true);assert.equal(f.calls.some(c=>c.kind==='dns'),false);assert.ok(f.events.some(e=>e.ruleId==='hostname-secret'));});
|
||||
@@ -0,0 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {hardenedElectronFuses,assertEmbeddedIntegrity} from '../scripts/package-fuses.mjs';
|
||||
test('production fuses close alternate Node entry points and require encrypted cookies and ASAR integrity',()=>{assert.deepEqual(hardenedElectronFuses,{runAsNode:false,enableCookieEncryption:true,enableNodeOptionsEnvironmentVariable:false,enableNodeCliInspectArguments:false,enableEmbeddedAsarIntegrityValidation:true,onlyLoadAppFromAsar:true});});
|
||||
test('Windows integrity metadata binds exact packaged archive header and rejects missing duplicate or altered values',()=>{const header='synthetic header',entry={file:'resources\\app.asar',alg:'SHA256',value:createHash('sha256').update(header).digest('hex')};assert.doesNotThrow(()=>assertEmbeddedIntegrity([entry],header));for(const entries of [[],[entry,entry],[{...entry,file:'app.asar'}],[{...entry,alg:'MD5'}],[{...entry,value:'0'.repeat(64)}]])assert.throws(()=>assertEmbeddedIntegrity(entries,header));assert.throws(()=>assertEmbeddedIntegrity([entry],header+'tampered'));});
|
||||
@@ -21,6 +21,8 @@ async function fixture(t){
|
||||
const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)});
|
||||
const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});};
|
||||
const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
|
||||
c.localRelay={proxyRules:'127.0.0.1:45678',proxyBypassRules:'<-loopback>',invalidate:tracked('relay-invalidate')};
|
||||
const originalSetProxy=c.browserSession.setProxy;c.browserSession.setProxy=async settings=>{assert.equal(settings.mode,'fixed_servers');assert.equal(settings.proxyRules,c.localRelay.proxyRules);assert.equal(settings.proxyBypassRules,'<-loopback>');await originalSetProxy(settings);};
|
||||
vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])};
|
||||
}
|
||||
test('actual unchanged policy exports correlated verification, bookmark and agent child stages',async t=>{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {deploymentProxyCredentials}=createRequire(import.meta.url)('../apps/browser/proxy-credentials.cjs');
|
||||
test('deployment proxy credentials require an exact host and port pin',()=>{
|
||||
const env={SAFETYNET_PROXY_USER:'fixture-user',SAFETYNET_PROXY_PASSWORD:'fixture-password',SAFETYNET_PROXY_AUTH_HOST:'proxy.company.example',SAFETYNET_PROXY_AUTH_PORT:'8443'};
|
||||
assert.deepEqual(deploymentProxyCredentials({host:'PROXY.COMPANY.EXAMPLE',port:8443},env),{username:'fixture-user',password:'fixture-password'});
|
||||
for(const selected of [{host:'attacker.example',port:8443},{host:'proxy.company.example',port:8080}])assert.equal(deploymentProxyCredentials(selected,env),null);
|
||||
assert.equal(deploymentProxyCredentials({host:'proxy.company.example',port:8443},{...env,SAFETYNET_PROXY_AUTH_HOST:undefined}),null);
|
||||
});
|
||||
@@ -7,25 +7,26 @@ import {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
const hook=source.slice(source.indexOf(" browserSession.webRequest.onBeforeRequest("),source.indexOf(" browserSession.webRequest.onBeforeSendHeaders("));
|
||||
function fixture(){
|
||||
let listener,completeDNS;const timers=[],events=[],callbacks=[],policy=defaultPolicy();
|
||||
let listener,completeDNS,enteredDNS;const dnsReady=new Promise(resolve=>enteredDNS=resolve);const timers=[],events=[],callbacks=[],policy=defaultPolicy();
|
||||
const context=vm.createContext({require:specifier=>require('../apps/browser/'+specifier.slice(2)),URL,Date,ready:true,policy,
|
||||
browserSession:{webRequest:{onBeforeRequest(_filter,fn){listener=fn;}}},tabs:{all:()=>[]},credentialBroker:undefined,credentialRequestCorrelations:new Map(),currentTabContext:()=>true,
|
||||
developerAssetRequestAllowed:()=>false,diagnosticSpan:(_name,_attrs,fn)=>fn(),core:{destinationAllowed:()=>true,inspect:()=>({decision:'allow'})},
|
||||
audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>completeDNS=resolve),
|
||||
audit:async(...args)=>events.push(args),recentRequests:new Map(),dnsGate:()=>new Promise(resolve=>{completeDNS=resolve;enteredDNS();}),localInspector:undefined,scanEnterprise:async()=>({decision:'allow',coverage:{complete:true}}),
|
||||
setTimeout:fn=>{timers.push(fn);return timers.length;},clearTimeout(){},Buffer,
|
||||
});
|
||||
vm.runInContext(source.slice(source.indexOf('function agentRequestOwnerAllowed(source)'),source.indexOf('function actorDestinationAllowed')),context);
|
||||
vm.runInContext(source.slice(source.indexOf('function requestDiagnostic('),source.indexOf('function credentialContext(')),context);
|
||||
vm.runInContext(source.slice(source.indexOf('function inspectOutgoingText('),source.indexOf('function responsePolicyHeaders(')),context);
|
||||
vm.runInContext(hook,context);listener({id:1,url:'https://example.test/',webContentsId:0,resourceType:'mainFrame',method:'GET'},r=>callbacks.push(r));
|
||||
return {timers,events,callbacks,complete:()=>completeDNS()};
|
||||
return {timers,events,callbacks,dnsReady,complete:()=>completeDNS()};
|
||||
}
|
||||
const settle=async()=>{for(let n=0;n<8;n++)await Promise.resolve();};
|
||||
test('native request deadline records a fixed reason and never authorizes a late result',async()=>{
|
||||
const f=fixture();await settle();f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true);
|
||||
const f=fixture();await f.dnsReady;await settle();f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,true);
|
||||
assert.deepEqual(f.events,[['navigation.block','request-inspection-timeout','example.test']]);f.complete();await settle();f.timers[0]();await settle();
|
||||
assert.equal(f.callbacks.length,1);assert.equal(f.events.length,1);
|
||||
});
|
||||
test('a completed native request cannot subsequently produce a timeout event',async()=>{
|
||||
const f=fixture();await settle();f.complete();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false);
|
||||
const f=fixture();await f.dnsReady;await settle();f.complete();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.callbacks[0].cancel,false);
|
||||
f.timers[0]();await settle();assert.equal(f.callbacks.length,1);assert.equal(f.events.length,0);
|
||||
});
|
||||
@@ -14,4 +14,4 @@ test('session bounds reject oversized and structurally invalid collections',()=>
|
||||
|
||||
test('benign query and fragment destinations retain exact navigation semantics',()=>{const value=sample();value.tabs[0].url='https://example.test/search?q=research&page=2#results';assert.equal(sanitizeSessionCollection(value).tabs[0].url,value.tabs[0].url);});
|
||||
test('persistence checks authority after protection before committing ciphertext',()=>withStore(({store,options,filePath})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const before=fs.readFileSync(filePath);let permitted=true;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);permitted=false;return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Forbidden after change'},{...owner,expectedRevision:store.list(owner).revision,assertCurrent:()=>{if(!permitted)throw Error('Authority changed');}}));assert.deepEqual(fs.readFileSync(filePath),before);assert.equal(store.list(owner).collections.length,1);}));
|
||||
test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options,filePath})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);}));
|
||||
test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
test('actual main startup exits with fixed diagnostics before unsafe runtime initialization',()=>{
|
||||
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
const prefix=source.slice(0,source.indexOf('// Force Chromium sandboxing'));
|
||||
const exits=[],messages=[],sentinel=Error('Fixture process terminated');
|
||||
const context={require:name=>name==='electron'?{app:{commandLine:{hasSwitch:flag=>flag==='inspect'}}}:require('../apps/browser/'+name.slice(2)),process:{exit:code=>{exits.push(code);throw sentinel;}},console:{error:message=>messages.push(message)}};
|
||||
assert.throws(()=>vm.runInNewContext(prefix,context),error=>error===sentinel);
|
||||
assert.deepEqual(exits,[1]);assert.deepEqual(messages,['Managed browser startup denied']);
|
||||
});
|
||||
@@ -21,3 +21,10 @@ test('broken command-line adapters fail closed with a static error',()=>{
|
||||
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;}
|
||||
});
|
||||
test('user feature overrides cannot disable native enforcement and never enter errors',()=>{
|
||||
for(const flag of ['--disable-features=PrivateFeature','--disable-blink-features=PrivateFeature','--enable-features=PrivateFeature','--enable-blink-features=PrivateFeature','--enable-experimental-web-platform-features','-disable-features=PrivateFeature','/disable-features=PrivateFeature','/DISABLE-FEATURES=PrivateFeature',' --disable-features=PrivateFeature ','\u0085/disable-features=PrivateFeature\u0085']){
|
||||
assert.throws(()=>assertSecureStartup(commandLine([]),[],['browser.exe',flag]),{message:'Unsafe browser startup configuration'});
|
||||
}
|
||||
assert.equal(assertSecureStartup(commandLine(['disable-features']),[],['browser.exe','--lang=de']),true,'Electron internal defaults are not user overrides');
|
||||
assert.throws(()=>assertSecureStartup(commandLine([]),[],[null]),{message:'Unsafe browser startup configuration'});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {defaultPolicy,inspect} from '../packages/core/policy.mjs';
|
||||
import {inspectionTextVariants} from '../packages/core/text-variants.mjs';
|
||||
test('literal secrets are detected in common nested wire encodings',()=>{
|
||||
const p=defaultPolicy();p.dlp.rules=[{id:'company-secret',label:'Secret',kind:'literal',value:'PROJECT REDWOOD',action:'block'}];
|
||||
const secret='PROJECT REDWOOD',b64=Buffer.from(secret).toString('base64');
|
||||
for(const text of [secret,b64,JSON.stringify({payload:b64}),encodeURIComponent(encodeURIComponent(secret)),JSON.stringify({prompt:'PROJECT\u0020REDWOOD'}),Buffer.from(b64).toString('base64')])assert.equal(inspect(Buffer.from(text),p).ruleId,'company-secret',text);
|
||||
for(const text of ['Public meeting agenda',JSON.stringify({prompt:'Public question',count:3}),'https://public.example/query?q=public+text'])assert.equal(inspect(Buffer.from(text),p).decision,'allow');
|
||||
});
|
||||
test('normalization is bounded and fails closed instead of skipping inspection',()=>{
|
||||
const p=defaultPolicy();const text=JSON.stringify(Array.from({length:80},(_,i)=>`Public text ${i}`));
|
||||
assert.equal(inspect(Buffer.from(text),p).ruleId,'normalization-limit');
|
||||
assert.equal(inspect(Buffer.from(JSON.stringify(Array.from({length:5000},(_,i)=>i))),p).ruleId,'normalization-limit');
|
||||
assert.throws(()=>inspectionTextVariants('Public text',2));
|
||||
let nested='PROJECT REDWOOD';for(let i=0;i<4;i++)nested=Buffer.from(nested).toString('base64');
|
||||
assert.equal(inspect(Buffer.from(nested),p).ruleId,'normalization-limit','Excessive encoding depth must not silently skip decoding');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {readFileSync} from 'node:fs';import vm from 'node:vm';const source=readFileSync(new URL('../apps/browser/transport-deny.cjs',import.meta.url),'utf8'),definition=source.slice(source.indexOf('function denyUninspectedRTC(){'),source.indexOf('\nif(process.sandboxed'));
|
||||
function world(){const context=vm.createContext({DOMException,RTCPeerConnection:function Native(){},webkitRTCPeerConnection:function NativeAlias(){},mozRTCPeerConnection:function OtherAlias(){}});vm.runInContext(definition+';denyUninspectedRTC();',context);return context;}
|
||||
test('unsupported RTC constructors and aliases fail with fixed NotAllowedError before native invocation',()=>{const c=world();for(const name of ['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection'])assert.throws(()=>vm.runInContext('new '+name+'()',c),e=>e.name==='NotAllowedError'&&e.message==='Managed WebRTC inspection is unavailable');});
|
||||
test('globals and their prototype constructor cannot expose or restore the native constructor',()=>{const c=world();assert.equal(vm.runInContext('Object.getOwnPropertyDescriptor(globalThis,"RTCPeerConnection").writable',c),false);assert.equal(vm.runInContext('Object.getOwnPropertyDescriptor(globalThis,"RTCPeerConnection").configurable',c),false);assert.throws(()=>vm.runInContext('new RTCPeerConnection.prototype.constructor()',c),e=>e.name==='NotAllowedError');assert.throws(()=>vm.runInContext('Object.defineProperty(globalThis,"RTCPeerConnection",{value:function(){}})',c));vm.runInContext('RTCPeerConnection=function(){}',c);assert.throws(()=>vm.runInContext('new RTCPeerConnection()',c),e=>e.name==='NotAllowedError');});
|
||||
test('preload rejects missing sandbox and installs only the fixed main-world function without a page bridge',()=>{for(const secure of [false,true]){let sends=0,stops=0,installed=0;const c=vm.createContext({process:{sandboxed:secure,contextIsolated:secure},window:{stop:()=>stops++},require:()=>({ipcRenderer:{send:()=>sends++},contextBridge:{executeInMainWorld:({func})=>{assert.equal(func.name,'denyUninspectedRTC');installed++;}}})});if(secure){vm.runInContext(source,c);assert.equal(installed,1);assert.equal(sends,0);}else{assert.throws(()=>vm.runInContext(source,c));assert.equal(installed,0);assert.equal(sends,1);assert.equal(stops,1);}}assert.doesNotMatch(source,/exposeInMainWorld|ipcRenderer\.invoke|node:|fs\./);});
|
||||
@@ -14,3 +14,5 @@ test('domain categories and intersected host allowances fail closed for disjoint
|
||||
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');});
|
||||
|
||||
test('agent background worker profiles only restrict and cannot widen central permission',()=>{const p=defaultPolicy(),inherit=UserProfileSchema.parse({id:'inherit',name:'Inherit',deviceIds:['one']}),deny=UserProfileSchema.parse({id:'deny',name:'Deny',deviceIds:['one'],features:{agentBackgroundWorkers:'deny'}});assert.equal(inherit.features.agentBackgroundWorkers,'inherit');assert.throws(()=>UserProfileSchema.parse({...deny,features:{agentBackgroundWorkers:'allow'}}));p.browser.allowAgentBackgroundWorkers=false;assert.equal(applyUserProfiles(p,{profiles:[inherit]},{id:'one'}).browser.allowAgentBackgroundWorkers,false);p.browser.allowAgentBackgroundWorkers=true;assert.equal(applyUserProfiles(p,{profiles:[inherit]},{id:'one'}).browser.allowAgentBackgroundWorkers,true);assert.equal(applyUserProfiles(p,{profiles:[inherit,deny]},{id:'one'}).browser.allowAgentBackgroundWorkers,false);assert.deepEqual(applyUserProfiles(p,{profiles:[inherit,deny]},{id:'one'}),applyUserProfiles(p,{profiles:[deny,inherit]},{id:'one'}));assert.equal(applyUserProfiles(p,{profiles:[deny]},{id:'other'}).browser.allowAgentBackgroundWorkers,true);});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {defaultPolicy,PolicySchema,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
import {AgentGrantSchema} from '../packages/core/security.mjs';
|
||||
const {startAgentControl}=createRequire(import.meta.url)('../apps/browser/agent-control.cjs');
|
||||
const {requestAgent}=createRequire(import.meta.url)('../apps/browser/agent-transport-client.cjs');
|
||||
|
||||
test('worker gate defaults off and survives signed policy verification',()=>{
|
||||
const p=defaultPolicy();assert.equal(p.browser.allowAgentBackgroundWorkers,false);
|
||||
delete p.browser.allowAgentBackgroundWorkers;
|
||||
assert.equal(PolicySchema.parse(p).browser.allowAgentBackgroundWorkers,false);
|
||||
p.browser.allowAgentBackgroundWorkers=true;
|
||||
const {privateKey,publicKey}=generateKeyPairSync('ed25519',{privateKeyEncoding:{type:'pkcs8',format:'pem'},publicKeyEncoding:{type:'spki',format:'pem'}});
|
||||
assert.equal(verifyPolicy(signPolicy(p,privateKey),publicKey).browser.allowAgentBackgroundWorkers,true);
|
||||
assert.equal(PolicySchema.safeParse({...p,browser:{...p.browser,allowAgentBackgroundWorkers:'true'}}).success,false);
|
||||
});
|
||||
|
||||
test('background permission cannot become an executable agent RPC',async t=>{
|
||||
const grant=AgentGrantSchema.parse({id:'worker-agent',name:'Worker',enabled:true,expiresAt:'2099-12-31T23:59:59Z',scopes:['network:background'],allowedHosts:['company.example'],generation:1});
|
||||
assert.deepEqual(grant.scopes,['network:background']);let executed=0;
|
||||
const api=await startAgentControl({authorize:async()=>({allowed:true,role:'agent',scopes:['network:background']}),actions:{'network:background':async()=>{executed++;}},audit:async()=>{}});
|
||||
t.after(()=>api.close());
|
||||
assert.equal((await requestAgent(api,{action:'network:background',args:{}})).status,400);
|
||||
assert.equal(executed,0);
|
||||
});
|
||||
Reference in new issue
Block a user