Add protected interaction evidence and measured debugging phases

This commit is contained in:
Karl committed 2026-10-13 18:04:02 +02:00
1 parent ea448de2d4
commit 244f01630b
42 files changed
+640 -87

No files matched your search

+5
View File
@@ -25,6 +25,11 @@ debugging, optimization and feature work, not merely implemented and left idle.
- Check collector `storedBytes` and exporter failures before long debug sessions. - Check collector `storedBytes` and exporter failures before long debug sessions.
The local protected receiver has a finite storage budget; missing exports or The local protected receiver has a finite storage budget; missing exports or
rejected writes must not be mistaken for a successful observability proof. rejected writes must not be mistaken for a successful observability proof.
- Check diagnostics `lifetimeFailedExports` as well as the current generation's
`failedExports`; disable/reconfiguration must not erase evidence of earlier
failures within the same browser process. Pre-enrollment startup has no signed
export authorization: use bounded local startup snapshots, and do not infer a
timeout's cause from later successful traces.
- Do not hide failed tests, weaken enforcement, or repeat a protected operation - Do not hide failed tests, weaken enforcement, or repeat a protected operation
because telemetry failed. Diagnostic traces are distinct from durable security because telemetry failed. Diagnostic traces are distinct from durable security
audit and encrypted forensic evidence. audit and encrypted forensic evidence.
+2 -2
View File
@@ -35,7 +35,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
| Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. | | Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. |
| Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. | | Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. |
| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings and bookmarks. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | | AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings and bookmarks. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. |
| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Protected download metadata evidence and governed analyst access; automatic interaction/content evidence remains incomplete. | | Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata and opt-in completed clipboard/agent-click metadata; general interaction and content recording remain incomplete. |
| Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | | Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md).
@@ -189,4 +189,4 @@ A project distribution license has not yet been selected. Third-party components
Security audit verification, optional external ML-DSA-87 signing and retention proof details are documented in [Audit integrity](docs/audit-integrity.md). Independent checkpoint delivery and production signing-key rotation remain under development. Security audit verification, optional external ML-DSA-87 signing and retention proof details are documented in [Audit integrity](docs/audit-integrity.md). Independent checkpoint delivery and production signing-key rotation remain under development.
Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); native content/interaction capture, device identity and production key custody remain under development. Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. [Interaction evidence](docs/interaction-evidence.md) covers completed guarded copy/cut/paste and agent clicks using bounded metadata. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); broader content/interaction capture, device identity and production key custody remain under development.
+24 -3
View File
@@ -53,7 +53,10 @@ function domAction(mode, selector, text, expectedUrl) {
} }
if (mode === 'click') { if (mode === 'click') {
if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked'); if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked');
e.click(); return {completed:true}; const rect=e.getBoundingClientRect(),target={tag:e.tagName.toLowerCase()},role=e.getAttribute('role');
if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;
const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}
e.click(); return {completed:true,target};
} }
if (mode === 'type') { if (mode === 'type') {
if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text'); if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text');
@@ -103,7 +106,7 @@ const hostMatches = (hostname, pattern) => {
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase(); const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p; return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
}; };
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,appControls={}} = {}) { function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) {
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required'); if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
const snapshot = (args,signal,action) => { const snapshot = (args,signal,action) => {
const c = getContext(args.sessionId); const c = getContext(args.sessionId);
@@ -142,6 +145,11 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false); const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false);
check(initial,args,signal); check(initial,args,signal);
if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)}; if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)};
if(mode==='click' && result?.completed===true && onInteraction){
const source=result.target,target=source&&['a','button','input'].includes(source.tag)?{tag:source.tag}:undefined;
if(target&&['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(source.role))target.role=source.role;
if(target&&['x','y'].every(k=>Number.isSafeInteger(source[k])&&source[k]>=0&&source[k]<=100000)){target.x=source.x;target.y=source.y;}
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
return {completed:result?.completed===true}; return {completed:result?.completed===true};
}; };
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
@@ -181,7 +189,20 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
sessions:async(args={}, {signal}={})=>{ sessions:async(args={}, {signal}={})=>{
const c=getContext(); const c=getContext();
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable'); if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true})); const version=c.policyVersion??c.policy.version;
const inventory=()=>{const rows=getSessions();if(!Array.isArray(rows)||rows.length>1000)throw Error('Invalid managed inventory');return rows.map(item=>{if(typeof item.title!=='string'||item.title.length>200)throw Error('Invalid managed title');return {id:item.id,title:item.title||'Tab',foreground:item.foreground===true};});};
const rows=inventory(),binding=JSON.stringify(rows);
const current=()=>{const latest=getContext();if(signal?.aborted||!latest?.ready||!latest.policy?.agents?.enabled||!latest.policy.agents.scopes?.includes('sessions')||latest.policy!==c.policy||(latest.policyVersion??latest.policy.version)!==version||(latest.policy.expiresAt&&Date.parse(latest.policy.expiresAt)<=Date.now())||(c.token&&!contextCurrent(c.token))||JSON.stringify(inventory())!==binding)throw Error('Managed session changed during action');};
// One bounded classification avoids reloading local OCR/semantic models
// per tab. A blocked aggregate masks every title; opaque IDs remain usable.
const candidates=rows.filter(row=>!secret(row.title));
let allow=false;
if(candidates.length){
const verdict=await inspect(candidates.map(row=>row.title).join('\n'),{...c,action:'sessions'});current();
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
allow=verdict.decision==='allow';
}
current();return rows.map(row=>({...row,title:allow&&!secret(row.title)?row.title.slice(0,160):'Protected page'}));
}, },
navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};}, navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};},
read:dom('read'),click:dom('click'),type:dom('type'),select:dom('select'),check:dom('check'), read:dom('read'),click:dom('click'),type:dom('type'),select:dom('select'),check:dom('check'),
+5 -4
View File
@@ -4,14 +4,15 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed'); if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed');
const field=document.activeElement; const field=document.activeElement;
const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly; const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly;
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');}; const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;}; const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;};
const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;}; const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;};
const targetFacts=(e,rect)=>{const target={tag:/^[A-Z][A-Z0-9]{0,31}$/.test(e.tagName)?e.tagName.toLowerCase():'unknown'};const role=e.getAttribute?.('role');if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;if(rect){const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}}return target;};
if(mode==='stage'){ if(mode==='stage'){
const snapshot={token,operation,field};let text=''; const snapshot={token,operation,field};let text='';
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){ if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied'); if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd; snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported'); if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported');
text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end); text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end);
}else{ }else{
@@ -19,10 +20,10 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable'); const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported'); if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported');
snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString(); snapshot.target=targetFacts(ancestor,range.getBoundingClientRect?.());snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString();
} }
if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit'); if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit');
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text}; snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text,target:snapshot.target};
} }
const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot; const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot;
if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable'); if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable');
+8 -3
View File
@@ -1,11 +1,12 @@
'use strict'; 'use strict';
const {randomUUID}=require('node:crypto'); const {randomUUID}=require('node:crypto');
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){ function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,onCompleted,timeoutMs=10000}){
const targetFacts=value=>{if(!value||!['a','button','input','textarea','select','div','span','p','body','unknown'].includes(value.tag))return undefined;const target={tag:value.tag};if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(value.role))target.role=value.role;if(['x','y'].every(k=>Number.isSafeInteger(value[k])&&value[k]>=0&&value[k]<=100000)) {target.x=value.x;target.y=value.y;}return target;};
let pending=null; let pending=null;
const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();}; const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();};
async function run(operation,webContents){ async function run(operation,webContents){
const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false; const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false;
const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};}; const denied=async ruleId=>{await audit('clipboard.block',ruleId,{action:operation,sessionId:c.tabId,outcome:'denied'});return{completed:false,ruleId};};
const work=async()=>{try{ const work=async()=>{try{
if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied'); if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied');
const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes); const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes);
@@ -19,7 +20,7 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
if(!current(c)||expired)return denied('clipboard-context-changed'); if(!current(c)||expired)return denied('clipboard-context-changed');
if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed'); if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed');
// Persist authorization before side effects; never audit raw clipboard text. // Persist authorization before side effects; never audit raw clipboard text.
await audit(`clipboard.${operation}`); await audit(`clipboard.${operation}`,undefined,{action:operation,sessionId:c.tabId,outcome:'authorized'});
if(!current(c)||expired)return denied('clipboard-context-changed'); if(!current(c)||expired)return denied('clipboard-context-changed');
const args={operation,token,url:c.url,text,limit,webContents:c.webContents}; const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
if(operation==='cut'){ if(operation==='cut'){
@@ -31,6 +32,10 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
const result=await commit(args); const result=await commit(args);
if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed'); if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');} if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');}
if(onCompleted && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(c.tabId||'')) {
await onCompleted({action:operation,sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,...(targetFacts(snapshot.target)?{target:targetFacts(snapshot.target)}:{}),bytes:Buffer.byteLength(text)});
if(!current(c)||expired)return denied('clipboard-context-changed');
}
return {completed:true}; return {completed:true};
}finally{try{await discard?.({token,webContents:c.webContents});}catch{}}}; }finally{try{await discard?.({token,webContents:c.webContents});}catch{}}};
try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);} try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);}
+5 -5
View File
@@ -10,8 +10,8 @@ const {resourceFromAttributes}=require('@opentelemetry/resources');
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http'); const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http'); const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api'); const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api');
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security']); const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
const operations=new Set(['agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']); const operations=new Set(['policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']); const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']); const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']); const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
@@ -34,7 +34,7 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}} function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;} function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;}
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000}; const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0; function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0;
// Parser timing is untrusted metadata: validate the entire batch before creating // Parser timing is untrusted metadata: validate the entire batch before creating
// spans, and attach it only to a currently recording browser operation. // spans, and attach it only to a currently recording browser operation.
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return; function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
@@ -42,7 +42,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);} const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
}catch{/* Diagnostics must never change inspection results. */}} }catch{/* Diagnostics must never change inspection results. */}}
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));} function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false; function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0}; let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0};
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}} const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
@@ -63,6 +63,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
} }
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);} async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);} async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
return {configure,span,event,importStages,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,activeSpans})}; return {configure,span,event,importStages,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
} }
module.exports={createDiagnostics,attributes,endpointAllowed}; module.exports={createDiagnostics,attributes,endpointAllowed};
+20 -1
View File
@@ -19,4 +19,23 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId; if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
return validateDownloadEvidence(result)?result:null; return validateDownloadEvidence(result)?result:null;
} }
module.exports={buildDownloadEvidence,validateDownloadEvidence}; const {InteractionEvidenceSchema}=require('../../packages/core/evidence-payload.cjs');
function validateInteractionEvidence(value){
if(!InteractionEvidenceSchema.safeParse(value).success)return false;
try{const u=new URL(value.pageUrl);return !credentialPatterns.some(p=>p.test(value.pageUrl)||p.test(decodeURIComponent(u.pathname)));}catch{return false;}
}
function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,outcome,bytes,captureSource},policy){
if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||outcome!=='completed')return null;
let page;try{page=new URL(url);if(!['http:','https:'].includes(page.protocol))return null;}catch{return null;}
if(target!==undefined){
if(!target||typeof target!=='object'||Array.isArray(target)||Object.keys(target).some(key=>!['tag','role','x','y'].includes(key))||typeof target.tag!=='string')return null;
// Native adapters expose bounded lowercase tags. Preserve their meaning in
// the canonical evidence representation without accepting arbitrary DOM text.
target={...target,tag:target.tag.toUpperCase()};
}
// Paths can carry identifiers or credentials. Automatic metadata uses origin
// only; detailed evidence remains an explicitly governed separate workflow.
const payload={pageUrl:page.origin+'/',action,sessionId,outcome:'completed',sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})};
return validateInteractionEvidence(payload)?structuredClone(payload):null;
}
module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence};
+20 -10
View File
@@ -1,16 +1,26 @@
'use strict'; 'use strict';
const {validateDownloadEvidence}=require('./evidence-capture.cjs'); const {validateDownloadEvidence,validateInteractionEvidence}=require('./evidence-capture.cjs');
function collectionAllowed(context){return !!context?.ready&&context.policy?.evidence?.enabled===true&&context.policy.evidence.captureDownloads===true&&Date.parse(context.policy.expiresAt)>Date.now();} function evidenceType(record){return record?.protectedEvidence?.type??'download';}
function validatedEvidence(payload){if(!validateDownloadEvidence(payload))throw Error('Invalid protected download evidence');return structuredClone(payload);} function policyAllows(context,type='download'){return ['download','interaction'].includes(type)&&context?.policy?.evidence?.enabled===true&&context.policy.evidence[type==='download'?'captureDownloads':'captureInteractions']===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function stripPendingEvidence(queue){return queue.map(({protectedEvidence,...event})=>event);} function collectionAllowed(context){return !!context?.ready&&policyAllows(context,context?.type??'download');}
function validatedEvidence(payload,type='download'){if(!(type==='download'?validateDownloadEvidence(payload):type==='interaction'&&validateInteractionEvidence(payload)))throw Error('Invalid protected evidence');return structuredClone(payload);}
function stripPendingEvidence(queue,context){return queue.map(record=>{if(!record.protectedEvidence)return record;if(context&&policyAllows(context,evidenceType(record)))return record;const {protectedEvidence,...event}=record;return event;});}
async function deliverAuditRecord({record,send,getContext}){ async function deliverAuditRecord({record,send,getContext}){
const {protectedEvidence,...event}=record;await send('/api/device/events',event); const {protectedEvidence,...event}=record;await send('/api/device/events',event);
if(!protectedEvidence)return {eventDelivered:true,evidenceDelivered:false}; if(!protectedEvidence)return {eventDelivered:true,evidenceDelivered:false};
const context=getContext(); const type=evidenceType(record);
if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&(context.policy.evidence?.enabled!==true||context.policy.evidence.captureDownloads!==true||!(Date.parse(context.policy.expiresAt)>Date.now())))return {eventDelivered:true,evidenceDiscarded:true}; function state(){const context=getContext();if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&!policyAllows(context,type))return 'discard';if(!context?.ready||!context.policy)return 'pause';return 'allow';}
if(!context?.ready||!context.policy)throw Error('Evidence delivery paused until managed context is ready'); const current=state();if(current==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(current==='pause')throw Error('Evidence delivery paused until managed context is ready');
const payload=validatedEvidence(protectedEvidence.payload); const payload=validatedEvidence(protectedEvidence.payload,type);
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type:'download',payload});if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};} if(type==='interaction'){
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:'agent.action';
if(event.kind!==expectedKind||payload.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(payload.action)||payload.captureSource==='agent-adapter'&&payload.action!=='click')throw Error('Invalid protected evidence source');
}
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type,payload});
// A successful network response must not resurrect collection after a
// concurrent policy revocation or a change of enrolled device.
const after=state();if(after==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(after==='pause')throw Error('Evidence delivery paused until managed context is ready');
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};}
catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;} catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;}
} }
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord}; module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord,evidenceType,policyAllows};
+32 -15
View File
@@ -146,11 +146,14 @@ function managementUrl(value){
return u.origin; return u.origin;
} }
const managementRequests=new Map(); const managementRequests=new Map();
const evidenceRequestTypes=new Map(),evidenceSendStorage=new (require('node:async_hooks').AsyncLocalStorage)();
function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));} function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));}
async function requestImpl(server,route,body,credential){ async function requestImpl(server,route,body,credential){
if(route==='/api/device/evidence'){ if(route==='/api/device/evidence'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected evidence identity unavailable'); if(!device||device.server!==server||device.token!==credential)throw Error('Protected evidence identity unavailable');
return require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body}); const identity=device,type=body?.type;if(!['download','interaction'].includes(type))throw Error('Invalid protected evidence type');
const assertCurrent=()=>{if(device!==identity||!require('./evidence-delivery.cjs').policyAllows({policy},type))throw Object.assign(Error('Protected evidence collection disabled'),{status:403});if(!ready)throw Error('Evidence delivery paused until managed context is ready');};
assertCurrent();return evidenceSendStorage.run({type},()=>require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body,assertCurrent,traceOperation:(operation,fn)=>diagnosticSpan('browser.evidence',{operation},fn)}));
} }
if(route==='/api/device/policy'){ if(route==='/api/device/policy'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable'); if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
@@ -159,13 +162,13 @@ async function requestImpl(server,route,body,credential){
const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`; const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`;
const correlation=diagnosticCorrelation();if(correlation.traceId&&correlation.spanId)headers.traceparent=`00-${correlation.traceId}-${correlation.spanId}-01`; const correlation=diagnosticCorrelation();if(correlation.traceId&&correlation.spanId)headers.traceparent=`00-${correlation.traceId}-${correlation.spanId}-01`;
if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1'; if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1';
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route); const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);if(evidenceSendStorage.getStore()?.type&&route.startsWith('/api/device/channel/evidence'))evidenceRequestTypes.set(controller,evidenceSendStorage.getStore().type);
try{ try{
const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch; const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch;
const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal}); const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal});
const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);} const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);}
const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data; const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data;
}finally{clearTimeout(timer);managementRequests.delete(controller);} }finally{clearTimeout(timer);managementRequests.delete(controller);evidenceRequestTypes.delete(controller);}
} }
function state(){return {startPage:{...(policy?.startPage||{}),news:ready?companyNews:[]},libraryAI:{suggestionsEnabled:ready&&!!policy?.ai.enabled&&!!policy?.library?.aiSuggestionsEnabled&&sectionAllowed(managedSettings(),'library')&&featureAllowed(managedSettings(),'ai'),autoApplyEnabled:ready&&!!policy?.library?.aiAutoApplyEnabled&&policy?.ai?.trustMode==='local',busy:libraryOrganizer?.isBusy()||false},bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentIdentityMode:policy?.agents.identityMode||'legacy',agentGrants:ready&&featureAllowed(managedSettings(),'agents')?approvedAgentGrants().map(({id,name,generation,expiresAt})=>({id,name,generation,expiresAt})):[],agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};} function state(){return {startPage:{...(policy?.startPage||{}),news:ready?companyNews:[]},libraryAI:{suggestionsEnabled:ready&&!!policy?.ai.enabled&&!!policy?.library?.aiSuggestionsEnabled&&sectionAllowed(managedSettings(),'library')&&featureAllowed(managedSettings(),'ai'),autoApplyEnabled:ready&&!!policy?.library?.aiAutoApplyEnabled&&policy?.ai?.trustMode==='local',busy:libraryOrganizer?.isBusy()||false},bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentIdentityMode:policy?.agents.identityMode||'legacy',agentGrants:ready&&featureAllowed(managedSettings(),'agents')?approvedAgentGrants().map(({id,name,generation,expiresAt})=>({id,name,generation,expiresAt})):[],agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};}
function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}} function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}}
@@ -192,7 +195,10 @@ function persistAudit(){
try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}} try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
} }
function enforceEvidencePolicy(){ function enforceEvidencePolicy(){
if(policy&&(policy.evidence?.enabled!==true||policy.evidence.captureDownloads!==true||!(Date.parse(policy.expiresAt)>Date.now()))){for(const [controller,route]of managementRequests)if(route==='/api/device/evidence'||route.startsWith('/api/device/channel/evidence'))controller.abort();if(auditQueue.some(e=>e.protectedEvidence)){auditQueue=require('./evidence-delivery.cjs').stripPendingEvidence(auditQueue);persistAudit();}} if(!policy)return;const delivery=require('./evidence-delivery.cjs');
for(const [controller,type]of evidenceRequestTypes)if(!delivery.policyAllows({policy},type))controller.abort();
const next=delivery.stripPendingEvidence(auditQueue,{policy});
if(next.some((record,index)=>record!==auditQueue[index])){auditQueue=next;persistAudit();}
} }
async function flushAudit(){ async function flushAudit(){
@@ -200,31 +206,35 @@ async function flushAudit(){
try{for(let n=0;n<100&&auditQueue.length;n++){await require('./evidence-delivery.cjs').deliverAuditRecord({record:auditQueue[0],send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})});auditQueue.shift();persistAudit();}} try{for(let n=0;n<100&&auditQueue.length;n++){await require('./evidence-delivery.cjs').deliverAuditRecord({record:auditQueue[0],send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})});auditQueue.shift();persistAudit();}}
catch{}finally{flushing=false;} catch{}finally{flushing=false;}
} }
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts){ async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
if(!device||!policy)return; if(!device||!policy)return;
// Operational correlation contains no rule text, hostname or forensic payload. // Operational correlation contains no rule text, hostname or forensic payload.
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{} try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{}
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;} if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy})?{protectedEvidence:{deviceId:device.id,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
catch{quarantine('Security events cannot be persisted. Contact your administrator.');} catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
} }
function auditAgent(event){ function auditAgent(event){
const action=event.action==='agent.read'?'read':event.action||'agent-request',outcome=event.outcome==='blocked'?'denied':event.outcome; const action=event.action==='agent.read'?'read':event.action||'agent-request',outcome=event.outcome==='blocked'?'denied':event.outcome;
const helper=require('../../packages/core/agent-audit.cjs'),session=helper.AgentAuditSchema.shape.sessionId.safeParse(event.sessionId); const helper=require('../../packages/core/agent-audit.cjs'),session=helper.AgentAuditSchema.shape.sessionId.safeParse(event.sessionId);
const agent=helper.agentAuditFacts({action,outcome,...(event.scope?{scope:event.scope}:{}),...(session.success&&session.data?{sessionId:session.data}:{}),...(event.principalId?{principalId:event.principalId,credentialGeneration:event.generation}:{})}); const agent=helper.agentAuditFacts({action,outcome,...(event.scope?{scope:event.scope}:{}),...(session.success&&session.data?{sessionId:session.data}:{}),...(event.principalId?{principalId:event.principalId,credentialGeneration:event.generation}:{})});
return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,undefined,agent); return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,event.protectedInteraction,agent);
} }
function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));} function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));}
async function applyPolicyImpl(envelope){ async function applyPolicyImpl(envelope){
const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id); const next=await diagnosticSpan('browser.policy',{operation:'policy.verify'},()=>assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id));
const {nextBookmarks,nextBookmarkJSON}=await diagnosticSpan('browser.policy',{operation:'policy.bookmarks'},async()=>{
const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0}); const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0});
const nextBookmarkJSON=JSON.stringify(nextBookmarks.items); const nextBookmarkJSON=JSON.stringify(nextBookmarks.items);
if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision'); if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision');
return {nextBookmarks,nextBookmarkJSON};
});
const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);}; const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);};
if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin'); if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin');
if(policy&&next.version===policy.version){ if(policy&&next.version===policy.version){
// Same versions must describe identical policy; reject equivocation. // Same versions must describe identical policy; reject equivocation.
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await reconcileAgentPolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return; if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
} }
configureDiagnostics({enabled:false});quarantine('Applying company policy…'); configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy; const proxy=next.proxy;
@@ -234,10 +244,10 @@ async function applyPolicyImpl(envelope){
app.configureHostResolver(resolverOptions(next.enterprise?.dns)); app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear(); await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache(); await managementSession.clearHostResolverCache();
policy=next;if(!next.evidence.enabled||!next.evidence.captureDownloads)enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics); policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}} await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}});
await reconcileAgentPolicy(); await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());
await audit('policy.applied'); await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied'));
} }
async function sync(){ async function sync(){
if(!device||syncing)return;syncing=true; if(!device||syncing)return;syncing=true;
@@ -385,7 +395,10 @@ async function ensureAgentAPI(){
if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy)); if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result; verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external; }if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
},audit:auditAgent}); },audit:auditAgent,onInteraction:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
}});
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};} for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{ for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
if(context.principalId&&['navigate','back','forward','reload','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};} if(context.principalId&&['navigate','back','forward','reload','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
@@ -640,7 +653,11 @@ app.whenReady().then(async()=>{
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();}, readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
writeClipboard:text=>clipboard.writeText(text), writeClipboard:text=>clipboard.writeText(text),
inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;}, inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;},
audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}} onCompleted:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'guarded-clipboard'},policy));
await audit(`clipboard.${metadata.action}`,undefined,undefined,undefined,undefined,payload,undefined,{action:metadata.action,sessionId:metadata.sessionId,outcome:'completed'});
},
audit:async(kind,ruleId,facts)=>{const parsed=require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.safeParse(facts);await audit(kind,ruleId,undefined,undefined,undefined,undefined,undefined,parsed.success?parsed.data:undefined);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}}
}); });
installClipboardControls(win.webContents);hooks(); installClipboardControls(win.webContents);hooks();
ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};}); ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};});
+12 -6
View File
@@ -19,19 +19,25 @@ async function protectedPolicyRequest({device,request,save}){
try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);} try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);}
finally{context.responseKey.fill(0);} finally{context.responseKey.fill(0);}
} }
async function protectedEvidenceRequest({device,request,save,payload}){ async function protectedEvidenceRequest({device,request,save,payload,assertCurrent=()=>{},traceOperation=async(_name,fn)=>fn()}){
assertCurrent();
await initializeSigningPin({device,request,save}); await initializeSigningPin({device,request,save});
assertCurrent();
const crypto=channel.evidenceChannel; const crypto=channel.evidenceChannel;
const challenge=await request(device.server,'/api/device/channel/evidence/challenge',{},device.token); const challenge=await traceOperation('evidence.challenge',()=>request(device.server,'/api/device/channel/evidence/challenge',{},device.token));
assertCurrent();
crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey); crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
const context=crypto.prepare(challenge,payload); let context;
try{ try{
const response=await request(device.server,'/api/device/channel/evidence',context.request,device.token); await traceOperation('evidence.encrypt',()=>{context=crypto.prepare(challenge,payload);});
assertCurrent();
const response=await traceOperation('evidence.send',()=>request(device.server,'/api/device/channel/evidence',context.request,device.token));
// Status is trusted only inside the authenticated encrypted acknowledgement. // Status is trusted only inside the authenticated encrypted acknowledgement.
const result=crypto.finish(response,challenge,context); assertCurrent();
const result=await traceOperation('evidence.ack',()=>crypto.finish(response,challenge,context));assertCurrent();
if(!result||Object.keys(result).sort().join(',')!=='body,status'||!Number.isInteger(result.status)||result.status<200||result.status>599||!result.body||typeof result.body!=='object'||Array.isArray(result.body))throw Error('Protected evidence acknowledgement invalid'); if(!result||Object.keys(result).sort().join(',')!=='body,status'||!Number.isInteger(result.status)||result.status<200||result.status>599||!result.body||typeof result.body!=='object'||Array.isArray(result.body))throw Error('Protected evidence acknowledgement invalid');
if(result.status>=300){const error=Error('Protected evidence submission refused');error.status=result.status;throw error;} if(result.status>=300){const error=Error('Protected evidence submission refused');error.status=result.status;throw error;}
return result.body; return result.body;
}finally{context.responseKey.fill(0);} }finally{context?.responseKey.fill(0);}
} }
module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin}; module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin};
+3 -1
View File
@@ -3,6 +3,7 @@ import {installRequestTelemetry,traceOperation} from './telemetry.mjs';
import transferFacts from '../../packages/core/transfer-facts.cjs'; import transferFacts from '../../packages/core/transfer-facts.cjs';
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs'; import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
import agentAudit from '../../packages/core/agent-audit.cjs'; import agentAudit from '../../packages/core/agent-audit.cjs';
import interactionAudit from '../../packages/core/interaction-facts.cjs';
import {installEvidence} from './evidence.mjs'; import {installEvidence} from './evidence.mjs';
import {installIncidents} from './incidents.mjs'; import {installIncidents} from './incidents.mjs';
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs'; import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
@@ -107,7 +108,8 @@ export function createApp(store,config) {
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true}); store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
}); });
app.post('/api/device/events',device,(req,res)=>{ app.post('/api/device/events',device,(req,res)=>{
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional()}).strict().parse(req.body); const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body);
if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching clipboard lifecycle event'});
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'}); if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'}); if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
if(b.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'}); if(b.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'});
+13 -6
View File
@@ -5,6 +5,7 @@ import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from
import {bootstrapAllowed} from './admin-access.mjs'; import {bootstrapAllowed} from './admin-access.mjs';
import {installEvidenceChannel} from './evidence-channel.mjs'; import {installEvidenceChannel} from './evidence-channel.mjs';
import {assertPrivateEvidenceKey} from './evidence-key.mjs'; import {assertPrivateEvidenceKey} from './evidence-key.mjs';
import {InteractionEvidenceSchema} from '../../packages/core/evidence.mjs';
const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden'); const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden');
const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment'); const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment');
@@ -35,11 +36,17 @@ function includesCredential(payload){
} }
function sourceMatches(source,payload,type){ function sourceMatches(source,payload,type){
let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;} let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;}
if(type==='interaction'&&['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)&&payload.action!==source.kind.split('.')[1])return false; if(type==='interaction'){
if(payload.outcome!=='completed'||!payload.sessionId)return false;
if(source.kind==='agent.action'){
if(payload.captureSource!=='agent-adapter'||metadata.agent?.action!=='click'||metadata.agent.outcome!=='completed'||payload.action!=='click'||metadata.agent.sessionId!==payload.sessionId)return false;
}else{
if(payload.captureSource!=='guarded-clipboard'||!['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)||payload.action!==source.kind.split('.')[1]||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='completed'||metadata.interaction?.sessionId!==payload.sessionId)return false;
}
}
if(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false; if(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false;
if(type==='download'&&metadata.file){for(const field of ['sha256','bytes'])if(metadata.file[field]!==undefined&&payload[field]!==metadata.file[field])return false;} if(type==='download'&&metadata.file){for(const field of ['sha256','bytes'])if(metadata.file[field]!==undefined&&payload[field]!==metadata.file[field])return false;}
if(metadata.host){try{if(new URL(payload.sourceUrl??payload.pageUrl).hostname.toLowerCase()!==String(metadata.host).toLowerCase())return false;}catch{return false;}} if(metadata.host){try{if(new URL(payload.sourceUrl??payload.pageUrl).hostname.toLowerCase()!==String(metadata.host).toLowerCase())return false;}catch{return false;}}
if(type==='interaction'&&source.kind==='agent.action'&&typeof metadata.ruleId==='string'){const action=metadata.ruleId.split(':')[0];if(['click','copy','cut','paste','submit'].includes(action)&&payload.action!==action)return false;}
return true; return true;
} }
function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));} function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));}
@@ -61,10 +68,10 @@ export function installEvidence(app,store,config,device,admin){
function recordEvidence(req,res){ function recordEvidence(req,res){
let plain;try{ let plain;try{
const b=input.parse(req.body),policy=store.effectivePolicy(req.device.id); const b=input.parse(req.body),policy=store.effectivePolicy(req.device.id);
if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads)return res.status(403).json({error:'Evidence collection is disabled by current policy'}); if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads||b.type==='interaction'&&!policy.evidence.captureInteractions)return res.status(403).json({error:'Evidence collection is disabled by current policy'});
const payload=payloads[b.type].parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});if(b.type==='interaction'&&payload.content!==undefined&&payload.contentSha256!==undefined&&createHash('sha256').update(payload.content,'utf8').digest('hex')!==payload.contentSha256)return res.status(400).json({error:'Evidence content digest mismatch'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'}); const payload=(b.type==='interaction'?InteractionEvidenceSchema:payloads[b.type]).parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'});
const source=db.prepare('SELECT id,kind,metadata FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'}); const source=db.prepare('SELECT id,kind,metadata FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'});
const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'}); const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'});
if(!sourceMatches(source,payload,b.type))return res.status(400).json({error:'Evidence facts contradict the source event'}); if(!sourceMatches(source,payload,b.type))return res.status(400).json({error:'Evidence facts contradict the source event'});
const prior=db.prepare('SELECT * FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior){ const prior=db.prepare('SELECT * FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior){
let same=false;try{if(prior.key_id===keyId){const priorPlain=decryptStoredEvidence(prior,key);try{same=JSON.stringify(JSON.parse(priorPlain.toString('utf8')))===JSON.stringify(payload);}finally{priorPlain.fill(0);}}}catch{} let same=false;try{if(prior.key_id===keyId){const priorPlain=decryptStoredEvidence(prior,key);try{same=JSON.stringify(JSON.parse(priorPlain.toString('utf8')))===JSON.stringify(payload);}finally{priorPlain.fill(0);}}}catch{}
@@ -87,7 +94,7 @@ export function installEvidence(app,store,config,device,admin){
app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{ app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{
const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'}); const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'});
const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'}); const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'});
let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=payloads[row.type].parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});} let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=(row.type==='interaction'?z.union([InteractionEvidenceSchema,payloads.interaction]):payloads[row.type]).parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});}
// Access must be durably attributable before plaintext leaves the service. // Access must be durably attributable before plaintext leaves the service.
store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason}); store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason});
res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload}); res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload});
+4 -2
View File
@@ -3,14 +3,16 @@ let evidencePolicyEditable=false;
function updateEvidencePolicyControls(){ function updateEvidencePolicyControls(){
$('evidence-policy-enabled').disabled=!evidencePolicyEditable; $('evidence-policy-enabled').disabled=!evidencePolicyEditable;
$('evidence-policy-downloads').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked; $('evidence-policy-downloads').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked;
$('evidence-policy-interactions').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked;
} }
function fillEvidencePolicy(p){ function fillEvidencePolicy(p){
$('evidence-policy-enabled').checked=p.evidence?.enabled===true; $('evidence-policy-enabled').checked=p.evidence?.enabled===true;
$('evidence-policy-downloads').checked=p.evidence?.captureDownloads===true; $('evidence-policy-downloads').checked=p.evidence?.captureDownloads===true;
$('evidence-policy-interactions').checked=p.evidence?.captureInteractions===true;
updateEvidencePolicyControls(); updateEvidencePolicyControls();
} }
const fillBeforeEvidencePolicy=fill;fill=function(p){fillBeforeEvidencePolicy(p);fillEvidencePolicy(p);}; const fillBeforeEvidencePolicy=fill;fill=function(p){fillBeforeEvidencePolicy(p);fillEvidencePolicy(p);};
const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked};return p;}; const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked,captureInteractions:$('evidence-policy-interactions').checked};return p;};
const permissionsBeforeEvidencePolicy=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeEvidencePolicy(principal);evidencePolicyEditable=!!principal?.permissions?.includes(independentApprovalRequired?'policy.prepare':'policy.write');updateEvidencePolicyControls();}; const permissionsBeforeEvidencePolicy=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeEvidencePolicy(principal);evidencePolicyEditable=!!principal?.permissions?.includes(independentApprovalRequired?'policy.prepare':'policy.write');updateEvidencePolicyControls();};
for(const id of ['evidence-policy-enabled','evidence-policy-downloads'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();}); for(const id of ['evidence-policy-enabled','evidence-policy-downloads','evidence-policy-interactions'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();});
if(policy)fillEvidencePolicy(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal); if(policy)fillEvidencePolicy(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal);
+1 -1
View File
@@ -15,7 +15,7 @@
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><div class="wide" id="agent-grants-controls"><h3>Individual agent identities</h3><label>Identity mode<select id="agent-identity-mode"><option value="legacy">Existing shared pairing (legacy)</option><option value="individual">Individual agent grants only</option></select></label><p>Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.</p><button type="button" id="agent-grant-add" class="secondary">Add agent grant</button><p id="agent-grants-status" role="status" aria-live="polite"></p><div id="agent-grants-editor"></div><p>Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.</p></div><details class="wide"><summary>Advanced source configuration: YARA, Sigma and visual references</summary><p class="muted">Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.</p><label>Security policy JSON<textarea id="security-json" rows="14"></textarea></label><label>Local agent policy JSON<textarea id="agents-json" rows="6" readonly></textarea></label></details><label>AI provider<select id="ai-provider"><optiLine truncated <section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><div class="wide" id="agent-grants-controls"><h3>Individual agent identities</h3><label>Identity mode<select id="agent-identity-mode"><option value="legacy">Existing shared pairing (legacy)</option><option value="individual">Individual agent grants only</option></select></label><p>Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.</p><button type="button" id="agent-grant-add" class="secondary">Add agent grant</button><p id="agent-grants-status" role="status" aria-live="polite"></p><div id="agent-grants-editor"></div><p>Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.</p></div><details class="wide"><summary>Advanced source configuration: YARA, Sigma and visual references</summary><p class="muted">Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.</p><label>Security policy JSON<textarea id="security-json" rows="14"></textarea></label><label>Local agent policy JSON<textarea id="agents-json" rows="6" readonly></textarea></label></details><label>AI provider<select id="ai-provider"><optiLine truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated <section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated <section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Download evidence collection</h2><p>Opt in through centrally signed policy. Both controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. This setting does not enable automatic interaction or clipboard-content capture. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diagnostics</label><label>Mode<select id="diagnostics-mode"><option value="operational">Operational</option><option value="debug">Detailed debug</option></select></label><label class="wide">Collector endpoint<input id="diagnostics-endpoint" type="url" placeholder="https://collector.company.example"></label><label>Operational sampling<input id="diagnostics-sample" type="number" min="0" max="1" step="0.01"></label><label>Debug sampling<input id="diagnostics-debug-sample" type="number" min="0" max="1" step="0.01"></label><label>Maximum queued records<input id="diagnostics-qLine truncated <section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture confirmed managed clipboard and agent-click metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, completed action and bounded target metadata. Page paths, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Physical user clicks and submissions are not automatically captured by this increment. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diagnostics</label><label>Mode<select id="diagnostics-mode"><option value="operational">Operational</option><option value="debug">Detailed debug</option></select></label><label class="wide">Collector endpoint<Line truncated
+13 -1
View File
@@ -34,7 +34,9 @@ The privileged browser instruments fixed operation families: IPC, local agent ac
Attributes are explicitly typed and allowlisted: registered operation/component enums, UUID session/device IDs, policy version, bounded byte/count fields, duration, decision, request type and status. Failure spans receive a status and a fixed exception category (`Error`, `TypeError` or `RangeError`), never an exception message or stack. Arbitrary operation names are not recorded. Returned model answers, credential values and inspected content are never inferred as diagnostic attributes. Attributes are explicitly typed and allowlisted: registered operation/component enums, UUID session/device IDs, policy version, bounded byte/count fields, duration, decision, request type and status. Failure spans receive a status and a fixed exception category (`Error`, `TypeError` or `RangeError`), never an exception message or stack. Arbitrary operation names are not recorded. Returned model answers, credential values and inspected content are never inferred as diagnostic attributes.
Detailed timing within Python parsing, OCR, YARA or model inference is not yet exported as its own child spans; the browser records the local-inspector call boundary. Renderer paint/layout and Chromium internal tracing are also separate future work. This is explicit application instrumentation, not a claim to trace every Chromium function. Signed debug mode imports validated private-pipe timing records as inspector child spans: inspection, parsing, OCR, OCR model initialization, YARA, macro extraction, semantic inference and semantic model initialization. Operational mode retains the call boundary. Renderer paint/layout, individual model kernels and Chromium internal tracing remain separate future work. This is explicit application instrumentation, not a claim to trace every Chromium function.
Protected evidence delivery has separate capture, challenge, encryption, send and acknowledgement stages, correlated with backend decrypt/store spans. Startup before enrollment or before a verified diagnostics policy cannot export telemetry; local smoke fixtures record bounded startup phase, window loading state and resource snapshots without URLs or credentials. Do not attribute an early startup timeout to a model or CPU pressure without measurements.
## Lifecycle and security behavior ## Lifecycle and security behavior
@@ -42,6 +44,16 @@ Only verified policy configures the provider. Reconfiguration immediately invali
Export failure never repeats the protected operation, changes its result or authorizes blocked data. Successful and failing functions retain their original results/errors. Normal shutdown attempts a bounded flush; diagnostics are intentionally nondurable and can be lost in a crash. Durable audit and forensic evidence require their separate pipelines. Export failure never repeats the protected operation, changes its result or authorizes blocked data. Successful and failing functions retain their original results/errors. Normal shutdown attempts a bounded flush; diagnostics are intentionally nondurable and can be lost in a crash. Durable audit and forensic evidence require their separate pipelines.
Local status distinguishes current-generation `failedExports` from process-lifetime
`lifetimeFailedExports`. Disabling or changing policy resets the former but retains
the latter until process restart. This counter does not export after disable.
Policy reconciliation now names signature verification, bookmark verification,
agent reconciliation, page configuration and durable audit phases. Same-version
refreshes preserve correlated child spans under the policy request. A new policy
deliberately disables the previous provider during proxy/DNS changes; only phases
after verified new diagnostics activation can export. That gap is intentional.
## Verification ## Verification
`node --test tests/diagnostics.test.mjs` exercises a real local OTLP receiver: parent/child traces, measured durations, correlated logs, typed attributes, password/token/URL/error sentinels absent, environment headers/resources excluded, disable/reconfigure queue invalidation, expiry, bounded batches and failed export without operation retry. `node --test tests/diagnostics.test.mjs` exercises a real local OTLP receiver: parent/child traces, measured durations, correlated logs, typed attributes, password/token/URL/error sentinels absent, environment headers/resources excluded, disable/reconfigure queue invalidation, expiry, bounded batches and failed export without operation retry.
+32
View File
@@ -64,3 +64,35 @@ when investigating latency. Do not optimize by removing an audit gate or retryin
an action. The proof is in `artifacts/individual-agent-native-proof.json`. an action. The proof is in `artifacts/individual-agent-native-proof.json`.
The final Windows package verifier checks current source and runs both shipped The final Windows package verifier checks current source and runs both shipped
legacy and individual DPAPI/PQ clients successfully. legacy and individual DPAPI/PQ clients successfully.
## Latest interaction and session measurements
The actual protected interaction proof uses trace
`32cbbb58088d193301c5f6ac530284af`: 30 spans, verified parent correlation,
capture 0.3353 ms, challenge 8.1735 ms, encryption 0.4718 ms, send 62.1817 ms
and acknowledgement 0.4234 ms. The backend store stage took 54.2629 ms.
Collector rejected writes were zero. These are one local reproduction's
measurements, not performance guarantees.
After session-title classification was added, the actual native session inventory
proof uses trace `64013e27cfc37bc3792983bc06149c87`: request 85.675 ms,
session adapter 1.0637 ms and durable audit phases 33.3975 and 47.2472 ms.
This fixture did not enable the semantic model; it cannot establish semantic-model
latency. The native proof confirms a confidential title is masked while preserving
its opaque session ID.
A parallel fixture timed out before enrollment and therefore before signed
diagnostics activation. Its isolated rerun and restart reached the shell in
794/806 ms. Safe local startup snapshots were added; the original timeout's cause
is unresolved. Do not describe that failure as a traced DLP/model regression.
The current Windows package proof and 515-test suite passed before the subsequent
export-counter refinement. Ignored proof artifacts remain local.
After the refinement and policy child stages, the final suite passes 522 tests.
Real signed policy/bookmark fixtures verify actual OTLP child spans and disabled
transitions. Windows build, packaged-runtime verification and the native tabs
proof pass. The native receiver attributes exports by actual `service.name`:
browser exports stay at 18 across revocation while backend collection remains
independent. The earlier aggregate assertion conflated the two services; its
failed run is retained rather than presented as successful. Do not retroactively
assign the original extra batch to a service without its original payload.
+36
View File
@@ -0,0 +1,36 @@
# Protected interaction evidence
Administrators separately enable `evidence.enabled`, `captureDownloads` and
`captureInteractions` through reviewed signed policy. All are off by default.
Interaction capture currently covers confirmed guarded copy/cut/paste and
completed agent clicks. It is not a recording of every physical user click.
Ordinary clipboard audit distinguishes authorization before the effect from
completion after the effect. Protected completion metadata contains website
origin, opaque session ID, action, structural target tag/role, bounded viewport
coordinates and optional byte count. Paths, queries, fragments, field values,
document text, selectors, labels and content hashes are excluded. Sensitive
fields do not produce this capture.
Agent completion capture is a separate audit phase from RPC completion; two
events do not mean two clicks. The native fixture verifies one click effect.
The server binds evidence to the accepted device event, action, source, session
and completed outcome. New writes reject raw interaction content. Existing
encrypted legacy evidence remains readable through restricted audited access.
The source event and typed protected payload are persisted in one DPAPI-protected
local queue record. Delivery acknowledges the ordinary event first and uses the
protected post-quantum management evidence channel. Policy and enrollment are
rechecked throughout delivery. Disabling interaction capture aborts matching
in-flight delivery and removes pending interaction evidence while preserving
ordinary audit and separately allowed download evidence. Existing retained
records still follow retention, legal-hold and purpose-bound analyst access.
Debug traces measure capture, challenge, encryption, send and acknowledgement,
with correlated server decrypt/store phases. These timing spans carry no raw
interaction contents. Evidence retention is independent of diagnostics.
Remaining coverage includes general physical interaction observation, controlled
content capture, all browser convenience clipboard paths and a proven scoped
credential-submission exception. Do not assume this feature is full page replay
or blanket password-field authorization.
+24
View File
@@ -477,3 +477,27 @@ Final current-source full Electron smoke passed in
was restarted only after process/listener identity verification and its health was restarted only after process/listener identity verification and its health
check passes; the protected collector remains healthy with zero rejected writes. check passes; the protected collector remains healthy with zero rejected writes.
Grant publication integration is covered by `tests/agent-grants-publication.test.mjs`. Grant publication integration is covered by `tests/agent-grants-publication.test.mjs`.
## Protected interaction and debugging increment
Default-off signed interaction capture now records confirmed guarded clipboard
operations and completed agent clicks as protected metadata, with exact
device/event/action/session binding. Native proof verifies analyst decryption,
audited access, independent review, live selective disable and continuing download
metadata capture. General physical-click replay and controlled content capture
remain open. See `docs/interaction-evidence.md`.
Agent session titles now pass aggregate DLP inspection before export; credential
titles are masked and an actual native confidential-title proof preserves the
session ID. Legitimate credential form-submission exceptions remain unverified.
Evidence delivery has correlated capture/challenge/encrypt/send/ack/store phases.
Policy reconciliation adds fixed named verification/bookmark/page/agent/audit
phases. Export failure totals survive disable/reconfiguration within the process.
Signed policy continues to govern telemetry; pre-enrollment startup uses bounded
local snapshots rather than unauthorized export. These changes have 520 passing
tests, including actual OTLP and signed policy fixtures. Final verification after
service-attributed native diagnostics tests passes 522 tests. The actual native
browser proves diagnostic export stops on revocation; Windows build and packaged
source/runtime verification pass. Production completeness
and the broader original open requirements are not implied.
+8
View File
@@ -0,0 +1,8 @@
'use strict';
const {z}=require('zod');
const InteractionEvidenceSchema=z.object({
pageUrl:z.string().max(4096).refine(value=>{try{const u=new URL(value);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URL must exclude credentials, query and fragment'),
action:z.enum(['click','copy','cut','paste','submit']),sessionId:z.string().uuid(),outcome:z.literal('completed'),captureSource:z.enum(['guarded-clipboard','agent-adapter']),bytes:z.number().int().min(0).max(16777216).optional(),
target:z.object({tag:z.enum(['A','BUTTON','INPUT','TEXTAREA','SELECT','TEXT','OTHER','DIV','SPAN','P','BODY','UNKNOWN']),role:z.enum(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option']).optional(),x:z.number().finite().min(0).max(10000000).optional(),y:z.number().finite().min(0).max(10000000).optional()}).strict().optional(),sensitiveField:z.literal(false)
}).strict();
module.exports={InteractionEvidenceSchema};
+13
View File
@@ -0,0 +1,13 @@
import {z} from 'zod';
// Collection controls are signed policy, independent of diagnostics and of
// each other. Neither permission is inferred from the global switch.
export const EvidencePolicySchema=z.object({
enabled:z.boolean().default(false),
captureDownloads:z.boolean().default(false),
captureInteractions:z.boolean().default(false)
}).strict().prefault({});
// Browser and server share the exact strict payload boundary.
import payloads from './evidence-payload.cjs';
export const InteractionEvidenceSchema=payloads.InteractionEvidenceSchema;
+11
View File
@@ -0,0 +1,11 @@
'use strict';
const {z}=require('zod');
const InteractionFactsSchema=z.object({action:z.enum(['copy','cut','paste','click','submit']),outcome:z.enum(['authorized','completed','denied']),sessionId:z.string().uuid()}).strict();
function interactionFacts(value){return InteractionFactsSchema.parse(value);}
function interactionFactsForEvent(kind,value){
const parsed=InteractionFactsSchema.safeParse(value);if(!parsed.success)return null;
const facts=parsed.data;
if(kind==='clipboard.block')return facts.outcome==='denied'&&['copy','cut','paste'].includes(facts.action)?facts:null;
return ['clipboard.copy','clipboard.cut','clipboard.paste'].includes(kind)&&facts.action===kind.split('.')[1]&&['authorized','completed'].includes(facts.outcome)?facts:null;
}
module.exports={InteractionFactsSchema,interactionFacts,interactionFactsForEvent};
+2 -1
View File
@@ -11,6 +11,7 @@ import {FileTransfersSchema} from './file-transfer.mjs';
import {UserAccessSchema} from './user-profiles.mjs'; import {UserAccessSchema} from './user-profiles.mjs';
import {LibraryPolicySchema} from './library.mjs'; import {LibraryPolicySchema} from './library.mjs';
import {BrandSchema,StartPageSchema} from './branding.mjs'; import {BrandSchema,StartPageSchema} from './branding.mjs';
import {EvidencePolicySchema} from './evidence.mjs';
const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); const host = z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
export const PolicySchema = z.object({ export const PolicySchema = z.object({
@@ -18,7 +19,7 @@ export const PolicySchema = z.object({
assignment:GroupAssignmentSchema.optional(), assignment:GroupAssignmentSchema.optional(),
userAccess:UserAccessSchema, userAccess:UserAccessSchema,
diagnostics:DiagnosticsSchema.prefault({}), diagnostics:DiagnosticsSchema.prefault({}),
evidence:z.object({enabled:z.boolean().default(false),captureDownloads:z.boolean().default(false)}).strict().prefault({}), evidence:EvidencePolicySchema,
fileTransfers:FileTransfersSchema.prefault({}), fileTransfers:FileTransfersSchema.prefault({}),
library:LibraryPolicySchema, library:LibraryPolicySchema,
expiresAt: z.string().datetime(), expiresAt: z.string().datetime(),
+5 -4
View File
@@ -1,10 +1,11 @@
import interactionAudit from './interaction-facts.cjs';
import agentAudit from './agent-audit.cjs'; import agentAudit from './agent-audit.cjs';
import transferFacts from './transfer-facts.cjs'; import transferFacts from './transfer-facts.cjs';
import requestTransferFacts from './request-transfer-facts.cjs'; import requestTransferFacts from './request-transfer-facts.cjs';
const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;}; const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;};
const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;}; const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;};
export function toECS(event,installationId='safetynet') { export function toECS(event,installationId='safetynet') {
const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert';
const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked'; const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked';
const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.'); const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.');
return { return {
@@ -19,7 +20,7 @@ export function toECS(event,installationId='safetynet') {
...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}), ...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}),
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}), ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}),
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}), ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}),
safetynet:{...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null), safetynet:{...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null),
content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null, content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null,
...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})} ...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})}
}; };
@@ -28,10 +29,10 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\
const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n'); const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n');
const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3); const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3);
export function toCEF(event,installationId='safetynet') { export function toCEF(event,installationId='safetynet') {
const m=event.metadata,agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert'; const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||m.decision==='block',alert=event.kind==='sigma.alert';
// suid identifies the verified agent capability principal, not an employee account. // suid identifies the verified agent capability principal, not an employee account.
// suser, when present, remains the independently supplied human actor. // suser, when present, remains the independently supplied human actor.
const fields={...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, const fields={...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind,
...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}), ...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}),
...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}), ...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}),
...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}), ...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}),
+11 -3
View File
@@ -3,10 +3,13 @@ const {app,BrowserWindow}=require('electron');
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs'); app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');
const wait=ms=>new Promise(r=>setTimeout(r,ms)),headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; const wait=ms=>new Promise(r=>setTimeout(r,ms)),headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'};
async function until(fn){for(let n=0;n<200;n++){const result=await fn();if(result)return result;await wait(100);}throw Error('Individual native fixture timed out');} const startupAt=Date.now();
function safeSnapshot(stage){return {stage,elapsedMs:Date.now()-startupAt,phase:process.env.SAFETYNET_SMOKE_PHASE,windows:BrowserWindow.getAllWindows().map(w=>({destroyed:w.isDestroyed(),loading:w.webContents.isLoading(),urlKind:w.webContents.getURL().endsWith('index.html')?'shell':w.webContents.getURL()===''?'empty':'other',children:w.contentView.children.length})),metrics:app.getAppMetrics().map(m=>({type:m.type,cpu:m.cpu.percentCPUUsage,idleWakeupsPerSecond:m.cpu.idleWakeupsPerSecond,memoryWorkingSetSize:m.memory.workingSetSize}))};}
async function until(fn,stage='native-wait'){const started=Date.now();for(let n=0;n<200;n++){const result=await fn();if(result)return result;await wait(100);}fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-timeout-'+process.env.SAFETYNET_SMOKE_PHASE+'.json'),JSON.stringify({...safeSnapshot(stage),waitElapsedMs:Date.now()-started},null,2));throw Error('Individual native fixture timed out: '+stage);}
const overview=async()=> (await fetch(process.env.SAFETYNET_SMOKE_SERVER+'/api/admin/overview',{headers})).json(); const overview=async()=> (await fetch(process.env.SAFETYNET_SMOKE_SERVER+'/api/admin/overview',{headers})).json();
app.whenReady().then(async()=>{let page;try{ app.whenReady().then(async()=>{let page;try{
const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;}); const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;},'initial-shell-load');
fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-startup-'+process.env.SAFETYNET_SMOKE_PHASE+'.json'),JSON.stringify(safeSnapshot('shell-ready'),null,2));
if(process.env.SAFETYNET_SMOKE_PHASE==='restart'){ if(process.env.SAFETYNET_SMOKE_PHASE==='restart'){
await until(async()=> (await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready); await until(async()=> (await win.webContents.executeJavaScript('window.safetynet.state()')).data.ready);
page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents; page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;
@@ -17,6 +20,7 @@ app.whenReady().then(async()=>{let page;try{
const proofFile=path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),proof=JSON.parse(fs.readFileSync(proofFile,'utf8'));proof.durableGenerationFloorAfterRestart=true;proof.oldDescriptorRejectedAfterRestart=true;fs.writeFileSync(proofFile,JSON.stringify(proof,null,2)); const proofFile=path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),proof=JSON.parse(fs.readFileSync(proofFile,'utf8'));proof.durableGenerationFloorAfterRestart=true;proof.oldDescriptorRejectedAfterRestart=true;fs.writeFileSync(proofFile,JSON.stringify(proof,null,2));
require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);console.log('Individual generation floor restart proof passed');app.exit(0);return; require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);console.log('Individual generation floor restart proof passed');app.exit(0);return;
} }
page=win.contentView.children.find(v=>v.webContents!==win.webContents)?.webContents;
const enroll=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`);assert.equal(enroll.ok,true); const enroll=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`);assert.equal(enroll.ok,true);
page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()); page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading());
assert.equal((await win.webContents.executeJavaScript('window.safetynet.agentConnection()')).ok,false); assert.equal((await win.webContents.executeJavaScript('window.safetynet.agentConnection()')).ok,false);
@@ -32,6 +36,10 @@ app.whenReady().then(async()=>{let page;try{
const requestFile=path.join(app.getPath('userData'),'individual-fixture-request.json'); const requestFile=path.join(app.getPath('userData'),'individual-fixture-request.json');
async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};} async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}
const sessions=await call(a,'sessions');assert.equal(sessions.exit,0);const sessionId=sessions.result[0].id; const sessions=await call(a,'sessions');assert.equal(sessions.exit,0);const sessionId=sessions.result[0].id;
assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/protected-title'})).exit,0);
await until(()=>page.getTitle()==='Synthetic confidential inventory title','protected-title-load');
const protectedInventory=await call(a,'sessions');assert.equal(protectedInventory.exit,0);assert.equal(protectedInventory.result.find(row=>row.id===sessionId)?.title,'Protected page','Page-controlled confidential title escaped inventory classification');
assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/allowed'})).exit,0);
assert.equal((await call(a,'read',{sessionId})).exit,0);assert.equal((await call(a,'application:state')).exit,1);assert.equal((await call(b,'application:state')).exit,0);assert.equal((await call(b,'read',{sessionId})).exit,1); assert.equal((await call(a,'read',{sessionId})).exit,0);assert.equal((await call(a,'application:state')).exit,1);assert.equal((await call(b,'application:state')).exit,0);assert.equal((await call(b,'read',{sessionId})).exit,1);
assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/allowed'})).exit,0,'Approved host navigation failed'); assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_SINK+'/allowed'})).exit,0,'Approved host navigation failed');
assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_FORBIDDEN})).exit,1,'Actor reached forbidden host'); assert.equal((await call(a,'navigate',{sessionId,url:process.env.SAFETYNET_SMOKE_FORBIDDEN})).exit,1,'Actor reached forbidden host');
@@ -51,6 +59,6 @@ app.whenReady().then(async()=>{let page;try{
assert.ok(traceProof,'Actual agent request trace missing'); assert.ok(traceProof,'Actual agent request trace missing');
const root=traceProof.stages.find(s=>s.operation==='agent.request');assert.equal(root.parentSpanId,null); const root=traceProof.stages.find(s=>s.operation==='agent.request');assert.equal(root.parentSpanId,null);
for(const operation of ['agent.decrypt','agent.authorize','agent.dispatch','agent.project','agent.audit','agent.encrypt'])assert.ok(traceProof.stages.some(s=>s.operation===operation&&s.parentSpanId===root.spanId&&Number.isFinite(s.durationMs)&&s.durationMs>=0),'Missing correlated stage '+operation); for(const operation of ['agent.decrypt','agent.authorize','agent.dispatch','agent.project','agent.audit','agent.encrypt'])assert.ok(traceProof.stages.some(s=>s.operation===operation&&s.parentSpanId===root.spanId&&Number.isFinite(s.durationMs)&&s.durationMs>=0),'Missing correlated stage '+operation);
fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),JSON.stringify({actualElectron:true,explicitGrantSelection:true,renderedGrantChoices:true,uiPairingBothActors:true,separateDPAPIDescriptors:true,noLegacyDescriptor:true,twoActors:true,scopeIntersection:true,approvedHostNavigation:true,forbiddenHostDeniedBeforeTransmission:true,forbiddenRedirectDeniedBeforeTransmission:true,forbiddenReceiverRequests:0,humanNavigationPreserved:true,ownedTabFetchAfterHumanNavigation:true,ownerlessSessionFetchBlockedBeforeTransmission:true,ownerlessReceiverRequests:0,targetedRevocation:true,unaffectedPeer:true,liveGenerationRollbackDenied:true,attributedAudit:true,traceProof},null,2));console.log('Individual agent native proof passed'); fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'individual-agent-native-proof.json'),JSON.stringify({actualElectron:true,classifiedInventoryRedactsTitlePreservesId:true,explicitGrantSelection:true,renderedGrantChoices:true,uiPairingBothActors:true,separateDPAPIDescriptors:true,noLegacyDescriptor:true,twoActors:true,scopeIntersection:true,approvedHostNavigation:true,forbiddenHostDeniedBeforeTransmission:true,forbiddenRedirectDeniedBeforeTransmission:true,forbiddenReceiverRequests:0,humanNavigationPreserved:true,ownedTabFetchAfterHumanNavigation:true,ownerlessSessionFetchBlockedBeforeTransmission:true,ownerlessReceiverRequests:0,targetedRevocation:true,unaffectedPeer:true,liveGenerationRollbackDenied:true,attributedAudit:true,traceProof},null,2));console.log('Individual agent native proof passed');
require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0); require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
}catch(error){console.error(error.stack);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}}); }catch(error){console.error(error.stack);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
+2 -1
View File
@@ -17,9 +17,10 @@ function requireSeparator(){return process.platform==='win32'?'\\':'/';}
const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
let forbiddenRequests=0,ownerlessRequests=0;const forbidden=createServer((_req,res)=>{forbiddenRequests++;res.end('Forbidden receiver');}).listen(0,'127.0.0.2');await new Promise(r=>forbidden.once('listening',r)); let forbiddenRequests=0,ownerlessRequests=0;const forbidden=createServer((_req,res)=>{forbiddenRequests++;res.end('Forbidden receiver');}).listen(0,'127.0.0.2');await new Promise(r=>forbidden.once('listening',r));
const forbiddenUrl=`http://127.0.0.2:${forbidden.address().port}/forbidden`; const forbiddenUrl=`http://127.0.0.2:${forbidden.address().port}/forbidden`;
const sink=createServer((req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({forbiddenRequests,ownerlessRequests}));return;}if(req.url==='/ownerless')ownerlessRequests++;if(req.url==='/redirect'){res.writeHead(302,{location:forbiddenUrl});res.end();return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Individual actor fixture</title><h1>Public actor fixture</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); const sink=createServer((req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({forbiddenRequests,ownerlessRequests}));return;}if(req.url==='/ownerless')ownerlessRequests++;if(req.url==='/redirect'){res.writeHead(302,{location:forbiddenUrl});res.end();return;}res.setHeader('content-type','text/html');res.end(req.url==='/protected-title'?'<!doctype html><title>Synthetic confidential inventory title</title><h1>Public actor fixture</h1>':'<!doctype html><title>Individual actor fixture</title><h1>Public actor fixture</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`; const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;
p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','navigate','read','application:state','settings:get'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'reader-a',name:'Reader A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','navigate','read'],allowedHosts:['127.0.0.1'],generation:1},{id:'state-b',name:'State B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['application:state'],allowedHosts:['127.0.0.1'],generation:1}]}; p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','navigate','read','application:state','settings:get'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'reader-a',name:'Reader A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','navigate','read'],allowedHosts:['127.0.0.1'],generation:1},{id:'state-b',name:'State B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['application:state'],allowedHosts:['127.0.0.1'],generation:1}]};
p.dlp.rules.push({id:'inventory-confidential-fixture',label:'Synthetic inventory fixture',action:'block',kind:'literal',value:'Synthetic confidential inventory title'});
p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'individual-fixture'); p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'individual-fixture');
const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true}; const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true};
const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const {app,BrowserWindow,clipboard,dialog}=require('electron');
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');
const wait=ms=>new Promise(r=>setTimeout(r,ms));
let stage='startup',page,originalClipboard;
async function until(fn){const end=Date.now()+45000;while(Date.now()<end){if(await fn())return;await wait(100);}throw Error(`Fixture deadline: ${stage}`);}
app.whenReady().then(async()=>{try{
let win;await until(()=>{win=BrowserWindow.getAllWindows()[0];return win&&!win.webContents.isLoading()&&win.webContents.getURL().endsWith('index.html');});await wait(300);
const result=await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`);assert.equal(result.ok,true);await wait(600);
page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;
const state=()=>win.webContents.executeJavaScript('window.safetynet.state()').then(r=>r.data),base=process.env.SAFETYNET_SMOKE_SERVER;
const headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'};
const overview=()=>fetch(base+'/api/admin/overview',{headers}).then(r=>r.json());
const records=async()=>((await overview()).events||[]).filter(e=>e.kind==='evidence.recorded'&&e.metadata.evidenceType==='interaction');
async function key(code){page.focus();page.sendInputEvent({type:'keyDown',keyCode:code,modifiers:['control']});page.sendInputEvent({type:'keyUp',keyCode:code,modifiers:['control']});await wait(800);await until(async()=>!(await state()).clipboardPending);}
originalClipboard=await clipboard.readText();
stage='copy';await page.executeJavaScript("(()=>{const r=document.createRange();r.selectNodeContents(document.getElementById('clipboard-source'));const s=getSelection();s.removeAllRanges();s.addRange(r)})()");await key('C');assert.equal(await clipboard.readText(),'Synthetic public selection');await until(async()=>(await records()).length===1);
stage='paste';await clipboard.writeText('Synthetic pasted text');await page.executeJavaScript("document.getElementById('public-input').focus()");await key('V');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'Synthetic pasted text');await until(async()=>(await records()).length===2);
stage='cut';await page.executeJavaScript("(()=>{const e=document.getElementById('public-input');e.focus();e.select()})()");await key('X');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'');assert.equal(await clipboard.readText(),'Synthetic pasted text');await until(async()=>(await records()).length===3);
stage='password exclusion';await page.executeJavaScript("document.getElementById('private-input').focus()");await clipboard.writeText('Synthetic password attempt');await key('V');assert.equal(await page.executeJavaScript("document.getElementById('private-input').value"),'never-export-this');await wait(600);assert.equal((await records()).length,3);
stage='agent click';const connection=await win.webContents.executeJavaScript('window.safetynet.agentConnection()');assert.equal(connection.ok,true);
const sessionId=(await state()).activeTabId,requestFile=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'interaction-request.json');
fs.writeFileSync(requestFile,JSON.stringify({action:'click',args:{sessionId,selector:'#public-button'}}));
const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.data.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',c=>output+=c);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});assert.equal(exit,0);assert.equal(JSON.parse(output).result.completed,true);assert.equal(await page.executeJavaScript("document.getElementById('public-button').dataset.clicked"),'1');await until(async()=>(await records()).length===4);
const recorded=await records(),payloads=[];
stage='analyst reveal';for(const e of recorded){const response=await fetch(base+`/api/admin/evidence/${e.metadata.evidenceId}?reason=investigation`,{headers});assert.equal(response.status,200);const item=await response.json();payloads.push(item.payload);}
assert.deepEqual(payloads.map(p=>p.action).sort(),['click','copy','cut','paste']);assert.ok(payloads.every(p=>p.sessionId===sessionId&&p.outcome==='completed'&&p.sensitiveField===false));assert.ok(payloads.some(p=>p.action==='click'&&p.captureSource==='agent-adapter'&&p.target?.tag==='BUTTON'));
const serialized=JSON.stringify(payloads),eventsBefore=(await overview()).events;
for(const marker of ['Synthetic public selection','Synthetic pasted text','Synthetic control label','Synthetic password attempt','never-export-this','#public-button','#private-input']){assert.equal(serialized.includes(marker),false);assert.equal(JSON.stringify(eventsBefore).includes(marker),false);}
assert.ok(eventsBefore.filter(e=>e.kind==='evidence.accessed').length>=4);
stage='admin policy screenshot';const adminWindow=new BrowserWindow({width:1440,height:1000,show:false,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true}});
await adminWindow.webContents.session.cookies.set({url:base,name:'sn_session',value:process.env.SAFETYNET_SMOKE_REVIEWER,httpOnly:true,sameSite:'strict'});await adminWindow.loadURL(base+'/');
await until(()=>adminWindow.webContents.executeJavaScript("(()=>{const e=document.getElementById('evidence-policy-interactions');return !!e&&e.checked&&!e.disabled})()"));
const crop=await adminWindow.webContents.executeJavaScript("(()=>{const section=document.getElementById('evidence-policy-controls');section.scrollIntoView({block:'start'});const r=section.getBoundingClientRect();return {x:Math.max(0,Math.floor(r.x)),y:Math.max(0,Math.floor(r.y)),width:Math.min(innerWidth-Math.max(0,Math.floor(r.x)),Math.ceil(r.width)),height:Math.min(innerHeight-Math.max(0,Math.floor(r.y)),Math.ceil(r.height))}})()");
await wait(200);fs.writeFileSync(path.resolve('artifacts/interaction-evidence-admin-controls.png'),(await adminWindow.webContents.capturePage(crop)).toPNG());adminWindow.destroy();
stage='independent policy review';const policy=(await overview()).policy;policy.version++;policy.evidence.captureInteractions=false;
const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(policy)});assert.equal(proposed.status,202);const review=await proposed.json();
const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:`sn_session=${process.env.SAFETYNET_SMOKE_REVIEWER}`,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>(await state()).policyVersion===policy.version&&(await state()).ready);
stage='disabled interaction';await page.executeJavaScript("document.getElementById('public-input').focus()");await clipboard.writeText('Synthetic post-disable text');await key('V');assert.equal(await page.executeJavaScript("document.getElementById('public-input').value"),'Synthetic post-disable text');await wait(600);assert.equal((await records()).length,4);
stage='download independently enabled';dialog.showSaveDialog=async()=>({canceled:false,filePath:path.join(process.env.SAFETYNET_SMOKE_USERDATA,'safe.bin')});await page.executeJavaScript("document.getElementById('safe-download').click()");await until(async()=>{const s=await state();return s.ready&&s.message==='Download scanned and saved.';});assert.deepEqual(fs.readFileSync(path.join(process.env.SAFETYNET_SMOKE_USERDATA,'safe.bin')),Buffer.from([0,1,2,3,255]));await until(async()=>((await overview()).events||[]).some(e=>e.kind==='evidence.recorded'&&e.metadata.evidenceType==='download'));
const after=(await overview()).events,sources=recorded.map(e=>after.find(source=>source.id===e.metadata.sourceEventId));assert.ok(sources.every(Boolean));assert.ok(sources.every(e=>e.metadata.traceId));
fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-proof.json'),JSON.stringify({actualElectron:true,nativeCopyPasteCut:true,agentClickExactlyOnce:true,passwordControlExcluded:true,noRawContentLabelsSelectors:true,completedActionsBoundToSession:true,analystAccessAudited:true,independentSignedDisable:true,actualAdminControlsScreenshot:true,disabledInteractionLeavesDownloadCapture:true,eventIds:sources.map(e=>e.id),traceIds:sources.map(e=>e.metadata.traceId)},null,2));
console.log('Native protected interaction evidence proof passed.');await clipboard.writeText(originalClipboard);require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
}catch(error){if(originalClipboard!==undefined)await clipboard.writeText(originalClipboard);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message},null,2));console.error(`Interaction evidence fixture failed at ${stage}: ${error.name}`);app.exit(1);}});
+53
View File
@@ -0,0 +1,53 @@
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { mkdtempSync,rmSync,mkdirSync,writeFileSync,readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join,resolve } from 'node:path';
import { spawn,execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createApp } from '../apps/server/app.mjs';
import {startTelemetry} from '../apps/server/telemetry.mjs';
import { openStore,digest } from '../apps/server/store.mjs';
const require=createRequire(import.meta.url),electron=require('electron');
if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)process.env.OTEL_EXPORTER_OTLP_ENDPOINT=process.env.SAFETYNET_TEST_OTLP_ENDPOINT;
const smokeTelemetry=startTelemetry();
const temporary=mkdtempSync(join(tmpdir(),'safetynet-smoke-'));mkdirSync('artifacts',{recursive:true});
const evidenceKeyFile=join(temporary,'evidence.key');writeFileSync(evidenceKeyFile,randomBytes(32),{mode:0o600});
if(process.platform==='win32')execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_EVIDENCE_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:evidenceKeyFile},stdio:'pipe'});
const sink=createServer((req,res)=>{if(req.url==='/download-safe'){res.setHeader('Content-Type','application/octet-stream');res.setHeader('Content-Disposition','attachment; filename="safe.bin"');res.end(Buffer.from([0,1,2,3,255]));return;}res.setHeader('Content-Type','text/html');res.end('<!doctype html><title>Interaction fixture</title><pre id="clipboard-source">Synthetic public selection</pre><input id="public-input" type="text"><input id="private-input" type="password" value="never-export-this"><button id="public-button" onclick="this.dataset.clicked=String(Number(this.dataset.clicked||0)+1)">Synthetic control label</button><a id="safe-download" href="/download-safe">Download</a>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
const inspectorToken=randomBytes(32).toString('base64url');
const inspectorProcess=spawn(resolve('.runtime/windows/python/python.exe'),['apps/inspector/server.py'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,INSPECTOR_TOKEN:inspectorToken,INSPECTOR_PORT:'0',INSPECTOR_PARENT_PID:String(process.pid)}});
const inspectorPort=await new Promise((resolve,reject)=>{
let output='';const deadline=setTimeout(()=>{inspectorProcess.kill();reject(Error('Fixture inspector did not start'));},40000);
inspectorProcess.once('exit',()=>{clearTimeout(deadline);reject(Error('Fixture inspector exited before startup'));});
inspectorProcess.on('error',reject);
inspectorProcess.stdout.on('data',chunk=>{
output+=chunk;
if(output.includes('\n')){
clearTimeout(deadline);
try{resolve(JSON.parse(output.split('\n')[0]).port);}catch{reject(Error('Fixture inspector startup rejected'));}
}
});
inspectorProcess.stderr.on('data',()=>{});
});
const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;p.brand={name:'Acme Secure Browser',color:'#245fbc'};p.dlp.rules=[{id:'secret-roadmap',label:'Roadmap',kind:'literal',value:'Secret Roadmap',action:'block'}];if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'smoke-user');
p.evidence={enabled:true,captureDownloads:true,captureInteractions:true};p.browser.allowDownloads=true;p.security.yara={enabled:true,rules:[{id:'test-marker',name:'Controlled malicious marker',source:'rule Block_Test { strings: $a = "SAFETYNET_BLOCK_TEST" condition: $a }'}],maxScanBytes:1048576};p.agents={enabled:true,scopes:['sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'],allowedHosts:['127.0.0.1'],allowSensitiveRead:false};if(process.env.SAFETYNET_TEST_OTLP_ENDPOINT)p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:process.env.SAFETYNET_TEST_OTLP_ENDPOINT,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);
const config={publicUrl:'http://127.0.0.1',secure:false,adminToken,evidenceKeyFile,requirePQManagement:true,requireIndependentPolicyApproval:true,inspectorUrl:`http://127.0.0.1:${inspectorPort}`,inspectorToken};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.on('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
const reviewerToken=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewerToken),Date.now()+600000,JSON.stringify({sub:'independent-fixture-reviewer',role:'admin'}));
const child=spawn(electron,['scripts/interaction-evidence-electron.cjs'],{stdio:'inherit',windowsHide:true,env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewerToken}});
const timeout=setTimeout(()=>child.kill(),180000);const exitCode=await new Promise(r=>{child.once('exit',code=>r(code??1));child.once('error',()=>r(1));});clearTimeout(timeout);
for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();inspectorProcess.kill();
// Only this mkdtemp-created fixture directory is removed.
if(!temporary.startsWith(join(tmpdir(),'safetynet-smoke-')))throw Error('Fixture cleanup boundary invalid');rmSync(temporary,{recursive:true,force:true});await smokeTelemetry?.shutdown();
if(exitCode===0&&process.env.SAFETYNET_TEST_OTLP_ENDPOINT){
const proof=JSON.parse(readFileSync(resolve('artifacts/interaction-evidence-native-proof.json'),'utf8')),endpoint=process.env.SAFETYNET_TEST_OTLP_ENDPOINT;
const health=await fetch(endpoint+'/health').then(r=>r.json());if(health.rejectedWrites!==0)throw Error('Collector reports rejected writes');
await new Promise(resolve=>setTimeout(resolve,3000));
const trace=await fetch(endpoint+'/api/trace/'+proof.traceIds[0]).then(r=>r.json()),spans=trace.spans||[],root=spans.find(s=>s.operation==='agent.request');
if(!root||!spans.some(s=>s.operation==='agent.dispatch'&&s.parentSpanId===root.spanId)||!['evidence.challenge','evidence.decrypt','evidence.store','evidence.encrypt'].every(name=>spans.some(s=>s.name===name&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Actual interaction evidence trace correlation missing');
const browserStages=['evidence.capture','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'];if(!browserStages.every(operation=>spans.some(s=>s.name==='browser.evidence'&&s.operation===operation&&spans.some(parent=>parent.spanId===s.parentSpanId))))throw Error('Browser evidence stages missing or uncorrelated');
writeFileSync(resolve('artifacts/interaction-evidence-trace-proof.json'),JSON.stringify({traceId:trace.traceId,linkedNativeEventId:proof.eventIds[0],spanCount:spans.length,agentRootMs:root.durationMs,browserEvidenceStages:spans.filter(s=>s.name==='browser.evidence').map(({operation,durationMs,parentSpanId})=>({operation,durationMs,parentSpanId})),protectedEvidenceStages:spans.filter(s=>s.name.startsWith('evidence.')).map(({name,durationMs,parentSpanId})=>({name,durationMs,parentSpanId})),parentCorrelation:true,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2));
}
process.exit(exitCode);
+1 -1
View File
@@ -30,6 +30,6 @@ app.whenReady().then(async()=>{try{
const collectorStatus=async()=>await (await fetch(process.env.SAFETYNET_TABS_COLLECTOR+'/status')).json();await until(async()=>{const status=await collectorStatus();return ['browser.ipc','browser.navigation','browser.dns','browser.request'].every(name=>status.names.includes(name))&&status.logs>0&&status.parentChild&&status.correlatedLogs;},'Actual native diagnostics export'); const collectorStatus=async()=>await (await fetch(process.env.SAFETYNET_TABS_COLLECTOR+'/status')).json();await until(async()=>{const status=await collectorStatus();return ['browser.ipc','browser.navigation','browser.dns','browser.request'].every(name=>status.names.includes(name))&&status.logs>0&&status.parentChild&&status.correlatedLogs;},'Actual native diagnostics export');
const enrolled=(await state()).deviceId;const response=await fetch(`${process.env.SAFETYNET_TABS_SERVER}/api/admin/devices/${enrolled}/revoke`,{method:'POST',headers:{Authorization:`Bearer ${process.env.SAFETYNET_TABS_ADMIN}`,'content-type':'application/json'},body:'{}'});assert.equal(response.status,200);await until(async()=>!(await state()).ready,'Central revocation'); const enrolled=(await state()).deviceId;const response=await fetch(`${process.env.SAFETYNET_TABS_SERVER}/api/admin/devices/${enrolled}/revoke`,{method:'POST',headers:{Authorization:`Bearer ${process.env.SAFETYNET_TABS_ADMIN}`,'content-type':'application/json'},body:'{}'});assert.equal(response.status,200);await until(async()=>!(await state()).ready,'Central revocation');
for(const page of [revokePageA,revokePageB]){assert.equal(page.isDestroyed(),false,'Revocation proof lost native view before request assertion');assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(base+'/revoked-public')},{method:'POST',body:'Public after revocation'}).then(()=>false).catch(()=>true)`),true,'Revoked background/foreground page sent request');} for(const page of [revokePageA,revokePageB]){assert.equal(page.isDestroyed(),false,'Revocation proof lost native view before request assertion');assert.equal(await page.executeJavaScript(`fetch(${JSON.stringify(base+'/revoked-public')},{method:'POST',body:'Public after revocation'}).then(()=>false).catch(()=>true)`),true,'Revoked background/foreground page sent request');}
await wait(500);const stoppedExports=(await collectorStatus()).exports;for(let i=0;i<5;i++)await state();await wait(600);assert.equal((await collectorStatus()).exports,stoppedExports,'Revoked browser continued diagnostic export'); await wait(500);const stoppedExports=await collectorStatus();for(let i=0;i<5;i++)await state();await wait(600);const afterRevocation=await collectorStatus();assert.ok(stoppedExports.exportsByService.browser>0,'Browser export attribution missing');assert.equal(afterRevocation.exportsByService.browser,stoppedExports.exportsByService.browser,'Revoked browser continued diagnostic export');fs.writeFileSync('artifacts/native-tabs-revocation-diagnostics.json',JSON.stringify({before:stoppedExports.exportsByService,after:afterRevocation.exportsByService,aggregateBefore:stoppedExports.exports,aggregateAfter:afterRevocation.exports},null,2));
const report={actualNativeWebContents:true,actualChromeUi:true,independentDomAndScroll:true,noReloadOnSwitch:true,independentHistory:true,allTabsDlp:true,siteClipboardDenied:true,remoteNodeUnavailable:true,closedContentsDestroyed:true,lastTabFreshGuard:true,centralRevocationAllTabs:true,actualBrowserDiagnostics:true,diagnosticsStoppedOnRevocation:true};fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/native-tabs-proof.json',JSON.stringify(report,null,2));console.log('NATIVE_TABS_PROOF_OK '+JSON.stringify(report));finish(0); const report={actualNativeWebContents:true,actualChromeUi:true,independentDomAndScroll:true,noReloadOnSwitch:true,independentHistory:true,allTabsDlp:true,siteClipboardDenied:true,remoteNodeUnavailable:true,closedContentsDestroyed:true,lastTabFreshGuard:true,centralRevocationAllTabs:true,actualBrowserDiagnostics:true,diagnosticsStoppedOnRevocation:true};fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync('artifacts/native-tabs-proof.json',JSON.stringify(report,null,2));console.log('NATIVE_TABS_PROOF_OK '+JSON.stringify(report));finish(0);
}catch(error){console.error('NATIVE_TABS_PROOF_FAILED '+error.stack);finish(1);}}); }catch(error){console.error('NATIVE_TABS_PROOF_FAILED '+error.stack);finish(1);}});
+4 -1
View File
@@ -11,7 +11,10 @@ import {startTelemetry} from '../apps/server/telemetry.mjs';
import {openStore,digest} from '../apps/server/store.mjs'; import {openStore,digest} from '../apps/server/store.mjs';
const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-native-tabs-')); const require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-native-tabs-'));
let sink,management,collector,store,child,sdk;const previousEndpoint=process.env.OTEL_EXPORTER_OTLP_ENDPOINT;const requests={},posts=[],diagnosticRequests=[];let privateSentinels=[]; let sink,management,collector,store,child,sdk;const previousEndpoint=process.env.OTEL_EXPORTER_OTLP_ENDPOINT;const requests={},posts=[],diagnosticRequests=[];let privateSentinels=[];
const diagnosticStatus=()=>{const spans=diagnosticRequests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);const logs=diagnosticRequests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);return {exports:diagnosticRequests.length,names:[...new Set(spans.map(span=>span.name))],spans:spans.length,logs:logs.length,timings:spans.every(span=>BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano)&&span.attributes.some(a=>a.key==='safetynet.duration.ms')),parentChild:spans.some(child=>child.parentSpanId&&spans.some(parent=>parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTrace:spans.some(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTraceIds:[...new Set(spans.filter(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)).map(span=>span.traceId))],correlatedLogs:logs.some(log=>log.traceId&&spans.some(span=>span.traceId===log.traceId&&span.spanId===log.spanId)),sensitivePresent:privateSentinels.some(secret=>JSON.stringify(diagnosticRequests).includes(secret))};}; // Backend and browser deliberately share this test receiver. Count requests per
// fixed OTLP resource identity so backend flushes cannot impersonate browser exports.
function exportCountsByService(records){const counts={browser:0,controlPlane:0,unknown:0};for(const record of records){const groups=[...(record.payload.resourceSpans||[]),...(record.payload.resourceLogs||[]),...(record.payload.resourceMetrics||[])],services=new Set(groups.map(group=>group.resource?.attributes?.find(a=>a.key==='service.name')?.value?.stringValue));if(services.has('safetynet-browser'))counts.browser++;if(services.has('safetynet-control-plane'))counts.controlPlane++;if(!services.size||[...services].some(v=>v!=='safetynet-browser'&&v!=='safetynet-control-plane'))counts.unknown++;}return counts;}
const diagnosticStatus=()=>{const spans=diagnosticRequests.filter(r=>r.path.endsWith('/traces')).flatMap(r=>r.payload.resourceSpans||[]).flatMap(r=>r.scopeSpans||[]).flatMap(r=>r.spans||[]);const logs=diagnosticRequests.filter(r=>r.path.endsWith('/logs')).flatMap(r=>r.payload.resourceLogs||[]).flatMap(r=>r.scopeLogs||[]).flatMap(r=>r.logRecords||[]);return {exports:diagnosticRequests.length,exportsByService:exportCountsByService(diagnosticRequests),names:[...new Set(spans.map(span=>span.name))],spans:spans.length,logs:logs.length,timings:spans.every(span=>BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano)&&span.attributes.some(a=>a.key==='safetynet.duration.ms')),parentChild:spans.some(child=>child.parentSpanId&&spans.some(parent=>parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTrace:spans.some(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)),crossProcessTraceIds:[...new Set(spans.filter(child=>child.name==='server.request'&&spans.some(parent=>parent.name==='browser.management'&&parent.spanId===child.parentSpanId&&parent.traceId===child.traceId)).map(span=>span.traceId))],correlatedLogs:logs.some(log=>log.traceId&&spans.some(span=>span.traceId===log.traceId&&span.spanId===log.spanId)),sensitivePresent:privateSentinels.some(secret=>JSON.stringify(diagnosticRequests).includes(secret))};};
try{ try{
collector=createServer((req,res)=>{if(req.method==='GET'&&req.url==='/status'){res.setHeader('content-type','application/json');res.end(JSON.stringify(diagnosticStatus()));return;}const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{diagnosticRequests.push({path:req.url,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=>collector.once('listening',r));const collectorUrl=`http://127.0.0.1:${collector.address().port}`;process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collectorUrl;sdk=startTelemetry(); collector=createServer((req,res)=>{if(req.method==='GET'&&req.url==='/status'){res.setHeader('content-type','application/json');res.end(JSON.stringify(diagnosticStatus()));return;}const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{diagnosticRequests.push({path:req.url,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=>collector.once('listening',r));const collectorUrl=`http://127.0.0.1:${collector.address().port}`;process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collectorUrl;sdk=startTelemetry();
sink=createServer((req,res)=>{const path=new URL(req.url,'http://localhost').pathname;requests[path]=(requests[path]||0)+1; sink=createServer((req,res)=>{const path=new URL(req.url,'http://localhost').pathname;requests[path]=(requests[path]||0)+1;
+1 -1
View File
@@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize
for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`); for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`);
assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale'); assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale');
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale'); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale');
for(const file of ['apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); for(const file of ['packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`);
for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`); for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`);
for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){ for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`);
+60
View File
@@ -304,3 +304,63 @@ test('history can leave a start page but reload never invokes native callback th
await assert.rejects(f.actions.back({}),/Native target/);await assert.rejects(f.actions.forward({}),/Native target/); await assert.rejects(f.actions.back({}),/Native target/);await assert.rejects(f.actions.forward({}),/Native target/);
await assert.rejects(f.actions.reload({}),/destination blocked/);assert.equal(called,2); await assert.rejects(f.actions.reload({}),/destination blocked/);assert.equal(called,2);
}); });
test('completed click forensic callback excludes selectors and text, and context changes suppress it',async()=>{
const events=[];const f=fixture({onInteraction:async value=>events.push(value)});
f.context.webContents.executeJavaScript=async()=>({completed:true,target:{tag:'button',role:'button',x:12,y:24,text:'SECRET',selector:'#private'}});
assert.deepEqual(await f.actions.click({selector:'#public'}),{completed:true});
assert.deepEqual(events,[{action:'click',sessionId:f.id,pageUrl:'https://work.example/',outcome:'completed',sensitiveField:false,target:{tag:'button',role:'button',x:12,y:24}}]);
f.context.webContents.executeJavaScript=async()=>{f.invalidate();return{completed:true,target:{tag:'button'}};};
await assert.rejects(f.actions.click({selector:'#public'}));assert.equal(events.length,1);
});
test('completed click callback failure is surfaced without repeating the committed click',async()=>{
let calls=0;const f=fixture({onInteraction:async()=>{throw Error('Evidence unavailable');}});
f.context.webContents.executeJavaScript=async()=>{calls++;return{completed:true};};
await assert.rejects(f.actions.click({selector:'#public'}),/Evidence unavailable/);assert.equal(calls,1);
});
test('session inventory classifies full titles and redacts corporate, semantic and credential content',async()=>{
for(const kind of ['corporate','semantic','credential','boundary']){
let calls=0,seen;const title=kind==='credential'?'password=SyntheticCredential':kind==='boundary'?'x'.repeat(150)+' password=SyntheticCredential':'Synthetic confidential title';
const f=fixture({getSessions:()=>[{id:'inventory',title,foreground:false}],inspect:async text=>{calls++;seen=text;return {decision:'block',ruleId:kind==='semantic'?'semantic-confidential':'corporate'};}});
f.context.policy.agents.allowSensitiveRead=true;
assert.deepEqual(await f.actions.sessions(),[{id:'inventory',title:'Protected page',foreground:false}]);
assert.equal(calls,['credential','boundary'].includes(kind)?0:1);if(calls)assert.equal(seen,title);
}
const title='x'.repeat(170),f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>{assert.equal(text.length,170);return {decision:'allow'};}});
f.setUrl('about:blank');assert.equal((await f.actions.sessions())[0].title.length,160);
});
test('session inventory refuses unavailable inspection, changed policy, native context and inventory',async()=>{
for(const kind of ['missing','outage','incomplete','throw','abort','version','policy','token','title']){
const controller=new AbortController();let title='Safe title',f;
f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async()=>{
if(kind==='missing')return undefined;if(kind==='outage')return {decision:'block',ruleId:'inspector-unavailable'};
if(kind==='incomplete')return {decision:'block',ruleId:'corporate',coverage:{complete:false}};if(kind==='throw')throw Error('Scanner failed');
if(kind==='abort')controller.abort();if(kind==='version')f.setVersion(2);if(kind==='policy')f.context.policy={...f.context.policy};if(kind==='token')f.invalidate();if(kind==='title')title='Changed title';
return {decision:'allow'};
}});
await assert.rejects(f.actions.sessions({}, {signal:controller.signal}),/unavailable|changed|Scanner failed/);
}
});
test('session titles use real core decoding and classify corporate suffix beyond wire truncation',async()=>{
for(const title of ['sk-SyntheticFixture123456789012345','x'.repeat(160)+' confidential']){
const f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>inspectPolicy(Buffer.from(text),{dlp:{maxBytes:10000,rules:[{id:'corporate',kind:'literal',value:'confidential'}]}})});
assert.equal((await f.actions.sessions())[0].title,'Protected page');
}
const title='sk%2DSyntheticFixture123456789012345',f=fixture({getSessions:()=>[{id:'inventory',title,foreground:true}],inspect:async text=>inspectPolicy(Buffer.from(text),{dlp:{maxBytes:10000,rules:[]}})});
assert.equal((await f.actions.sessions())[0].title,'Protected page');
});
test('inventory uses one bounded aggregate scan and masks all titles when any classified title blocks',async()=>{
for(const decision of ['allow','block']){
let calls=0;const rows=[{id:'public',title:'Public page',foreground:true},{id:'corporate',title:'Registered corporate page',foreground:false},{id:'credential',title:'password=SyntheticCredential',foreground:false}];
const f=fixture({getSessions:()=>rows,inspect:async text=>{calls++;assert.equal(text,'Public page\nRegistered corporate page');return {decision,ruleId:decision==='block'?'corporate':null};}});
const result=await f.actions.sessions();assert.equal(calls,1);assert.deepEqual(result.map(row=>row.id),rows.map(row=>row.id));
assert.deepEqual(result.map(row=>row.title),decision==='allow'?['Public page','Registered corporate page','Protected page']:['Protected page','Protected page','Protected page']);
}
for(const rows of [Array.from({length:1001},()=>({id:'inventory',title:'Safe'})),[{id:'inventory',title:'x'.repeat(201)}]]){
let calls=0;const f=fixture({getSessions:()=>rows,inspect:async()=>{calls++;return {decision:'allow'};}});await assert.rejects(f.actions.sessions(),/Invalid managed/);assert.equal(calls,0);
}
});
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url),source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
const delivery=require('../apps/browser/evidence-delivery.cjs');
function fixture(){const writes=[],aborts=[];const policy={version:1,expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true,captureInteractions:true}};const c=vm.createContext({policy,ready:true,device:{id:'device-a'},auditQueue:[],evidenceRequestTypes:new Map(),require:id=>id.startsWith('node:')?require(id):id.includes('evidence-delivery')?delivery:require('../packages/core/'+id.split('/').at(-1)),persistAudit:()=>writes.push('persist'),flushAudit(){},diagnostics:{event(){}},diagnosticContext:x=>x,diagnosticCorrelation:()=>({}),quarantine:()=>{throw Error('Unexpected quarantine');}});vm.runInContext(source.slice(source.indexOf('function enforceEvidencePolicy(){'),source.indexOf('async function flushAudit()')),c);vm.runInContext(source.slice(source.indexOf('async function audit(kind'),source.indexOf('function auditAgent(event)')),c);return {c,writes,aborts};}
const sessionId='00000000-0000-4000-8000-000000000001';
test('browser persists source completion and protected typed evidence in one audit tuple',async()=>{const f=fixture(),payload={pageUrl:'https://example.test/',action:'copy',sessionId,outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false,target:{tag:'INPUT'},bytes:8};await f.c.audit('clipboard.copy',undefined,undefined,undefined,undefined,payload,undefined,{action:'copy',sessionId,outcome:'completed'});assert.equal(f.writes.length,1);const record=f.c.auditQueue[0];assert.equal(record.interaction.outcome,'completed');assert.equal(record.protectedEvidence.type,'interaction');assert.equal(record.protectedEvidence.payload.target.tag,'INPUT');assert.equal(record.pageUrl,undefined);assert.equal(record.target,undefined);assert.equal(record.eventId.length,36);});
test('type-specific policy disable aborts only matching sends and strips only matching pending evidence',()=>{const f=fixture(),ordinary={eventId:'ordinary'},download={eventId:'download',protectedEvidence:{type:'download'}},interaction={eventId:'interaction',protectedEvidence:{type:'interaction'}};f.c.auditQueue=[ordinary,download,interaction];for(const type of ['download','interaction'])f.c.evidenceRequestTypes.set({abort:()=>f.aborts.push(type)},type);f.c.policy.evidence.captureInteractions=false;f.c.enforceEvidencePolicy();assert.deepEqual(f.aborts,['interaction']);assert.equal(f.c.auditQueue[0],ordinary);assert.equal(f.c.auditQueue[1],download);assert.equal(f.c.auditQueue[2].protectedEvidence,undefined);assert.equal(f.c.auditQueue[2].eventId,'interaction');assert.equal(f.writes.length,1);f.c.enforceEvidencePolicy();assert.equal(f.writes.length,1);});
+10
View File
@@ -49,3 +49,13 @@ test('independent clipboard review: selected hidden, inert, script and ancestor-
test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{ 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);} for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);}
}); });
test('clipboard completion metadata follows actual commit and native write, never exposes content',async()=>{
const frame={},wc={mainFrame:frame,focusedFrame:frame},steps=[],events=[];
let c={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc};
const options={getContext:()=>c,stage:async()=>({text:'safe text',target:{tag:'input',x:10,y:20,text:'SECRET',selector:'#secret'}}),validate:async()=>({completed:true}),commit:async()=>{steps.push('commit');return{completed:true};},readClipboard:async()=> 'safe paste',writeClipboard:async()=>steps.push('write'),inspect:async()=>({decision:'allow'}),audit:async(kind,rule,facts)=>{steps.push('audit');assert.equal(JSON.stringify(facts).includes('safe text'),false);},onCompleted:async metadata=>{steps.push('completed');events.push(metadata);}};
const guard=createClipboardGuard(options);assert.equal((await guard.perform('copy',wc)).completed,true);
assert.deepEqual(steps,['audit','commit','write','completed']);assert.deepEqual(events,[{action:'copy',sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,target:{tag:'input',x:10,y:20},bytes:9}]);
events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0);
options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0);
});
+12
View File
@@ -40,3 +40,15 @@ test('inspector timing batches export exact nested spans only inside live debug
}); });
test('inspector timing validation rejects entire malformed batches and hostile accessors',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const record={id:1,parentId:null,stage:'parse',startOffsetMs:0,durationMs:5,status:'ok'},good=()=>({baseUnixMs:Date.now()-20,records:[record]});const bad=[{...good(),baseUnixMs:Date.now()-61000},{...good(),baseUnixMs:Date.now()+3000},{...good(),records:[record,record]},{...good(),records:[{...record,parentId:2}]},{...good(),records:[{...record,stage:'PASSWORD_SECRET'}]},{...good(),records:[{...record,durationMs:NaN}]},{...good(),records:[{...record,startOffsetMs:29999,durationMs:2}]},{...good(),records:Array(65).fill(record)}];const hostile={};Object.defineProperty(hostile,'baseUnixMs',{get(){throw Error('getter must not run');}});bad.push(hostile);await diagnostics.span('browser.inspect.local',{},async()=>{for(const batch of bad)assert.doesNotThrow(()=>diagnostics.importStages(batch));});await diagnostics.flush();assert.equal(spans(requests).length,1);diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.span('browser.inspect.local',{},async()=>diagnostics.importStages(good()));await diagnostics.flush();assert.equal(spans(requests).length,1); test('inspector timing validation rejects entire malformed batches and hostile accessors',{timeout:15000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const record={id:1,parentId:null,stage:'parse',startOffsetMs:0,durationMs:5,status:'ok'},good=()=>({baseUnixMs:Date.now()-20,records:[record]});const bad=[{...good(),baseUnixMs:Date.now()-61000},{...good(),baseUnixMs:Date.now()+3000},{...good(),records:[record,record]},{...good(),records:[{...record,parentId:2}]},{...good(),records:[{...record,stage:'PASSWORD_SECRET'}]},{...good(),records:[{...record,durationMs:NaN}]},{...good(),records:[{...record,startOffsetMs:29999,durationMs:2}]},{...good(),records:Array(65).fill(record)}];const hostile={};Object.defineProperty(hostile,'baseUnixMs',{get(){throw Error('getter must not run');}});bad.push(hostile);await diagnostics.span('browser.inspect.local',{},async()=>{for(const batch of bad)assert.doesNotThrow(()=>diagnostics.importStages(batch));});await diagnostics.flush();assert.equal(spans(requests).length,1);diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.span('browser.inspect.local',{},async()=>diagnostics.importStages(good()));await diagnostics.flush();assert.equal(spans(requests).length,1);
}); });
// Keep operational failure evidence after a centrally requested generation change.
test('export failure totals survive disable and reconfiguration without resending operations',{timeout:10000},async t=>{
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());let calls=0;
diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250});
await diagnostics.span('browser.request',{},async()=>{calls++;return 'allowed';});await diagnostics.flush();
const failed=diagnostics.status().lifetimeFailedExports;assert.ok(failed>=1);
diagnostics.configure({enabled:false});assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,failed);
await diagnostics.span('browser.request',{},async()=>{calls++;});await diagnostics.flush();assert.equal(requests.length,0);
diagnostics.configure(config(endpoint));await diagnostics.span('browser.request',{},async()=>{calls++;});await diagnostics.flush();
assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,failed);assert.equal(calls,3);assert.equal(spans(requests).length,1);
});
+5 -5
View File
@@ -2,10 +2,10 @@ import test from 'node:test';import assert from 'node:assert/strict';import {mkd
import channel from '../packages/core/policy-channel.cjs';import browser from '../apps/browser/management-channel.cjs';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; import channel from '../packages/core/policy-channel.cjs';import browser from '../apps/browser/management-channel.cjs';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs';
async function fixture(t){ async function fixture(t){
const dir=mkdtempSync(join(tmpdir(),'safetynet-pq-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});if(process.platform==='win32')execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_PQ_EVIDENCE_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_PQ_EVIDENCE_KEY:keyFile},stdio:'pipe'}); const dir=mkdtempSync(join(tmpdir(),'safetynet-pq-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});if(process.platform==='win32')execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_PQ_EVIDENCE_KEY -AclObject $acl"],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_PQ_EVIDENCE_KEY:keyFile},stdio:'pipe'});
const store=openStore(dir),p=store.get('policy');p.evidence={enabled:true,captureDownloads:true};store.put('policy',p);const token='synthetic-pq-evidence-device-token-123456789',otherToken='synthetic-pq-evidence-other-token-123456789',id='device-a';store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(token));store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run('device-b',digest(otherToken)); const store=openStore(dir),p=store.get('policy');p.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',p);const token='synthetic-pq-evidence-device-token-123456789',otherToken='synthetic-pq-evidence-other-token-123456789',id='device-a';store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(token));store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run('device-b',digest(otherToken));
const config={publicUrl:'http://127.0.0.1',secure:false,evidenceKeyFile:keyFile,requirePQManagement:true},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();assert.ok(dir.startsWith(join(tmpdir(),'safetynet-pq-evidence-')));rmSync(dir,{recursive:true,force:true});}); const config={publicUrl:'http://127.0.0.1',secure:false,evidenceKeyFile:keyFile,requirePQManagement:true},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();assert.ok(dir.startsWith(join(tmpdir(),'safetynet-pq-evidence-')));rmSync(dir,{recursive:true,force:true});});
const base=`http://127.0.0.1:${server.address().port}`,wire=[];const api=async(route,body,credential=token)=>{wire.push({route,body});const r=await fetch(base+route,{method:body?'POST':'GET',headers:{authorization:`Bearer ${credential}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};}; const base=`http://127.0.0.1:${server.address().port}`,wire=[];const api=async(route,body,credential=token)=>{wire.push({route,body});const r=await fetch(base+route,{method:body?'POST':'GET',headers:{authorization:`Bearer ${credential}`,...(body?{'content-type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};
const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1});const payload={eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/file',action:'copy',content:'CONFIDENTIAL_SYNTHETIC_PQ_EVIDENCE',sensitiveField:false}}; const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});const payload={eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/file',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}};
const device={id,token,server:base,publicKey:store.publicKey(),channelSignatureKey:store.get('channelSigningKeys').publicKey};const make=async()=>{const challenge=await api('/api/device/channel/evidence/challenge',{});assert.equal(challenge.status,200);channel.evidenceChannel.verifyChallenge(challenge.body,id,device.publicKey,device.channelSignatureKey);return {bundle:challenge.body,context:channel.evidenceChannel.prepare(challenge.body,payload)};};return {store,api,wire,payload,device,make}; const device={id,token,server:base,publicKey:store.publicKey(),channelSignatureKey:store.get('channelSigningKeys').publicKey};const make=async()=>{const challenge=await api('/api/device/channel/evidence/challenge',{});assert.equal(challenge.status,200);channel.evidenceChannel.verifyChallenge(challenge.body,id,device.publicKey,device.channelSignatureKey);return {bundle:challenge.body,context:channel.evidenceChannel.prepare(challenge.body,payload)};};return {store,api,wire,payload,device,make};
} }
test('real central PQ evidence storage rejects plaintext and supports authenticated idempotent retry',async t=>{ test('real central PQ evidence storage rejects plaintext and supports authenticated idempotent retry',async t=>{
@@ -13,9 +13,9 @@ test('real central PQ evidence storage rejects plaintext and supports authentica
const request=async(_base,route,body,credential)=>{const response=await f.api(route,body,credential);if(response.status>=400)throw Object.assign(Error('Transport refused'),{status:response.status});return response.body;}; const request=async(_base,route,body,credential)=>{const response=await f.api(route,body,credential);if(response.status>=400)throw Object.assign(Error('Transport refused'),{status:response.status});return response.body;};
const created=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.match(created.id,/^[0-9a-f-]{36}$/);const again=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.equal(again.id,created.id);assert.equal(again.replayed,true); const created=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.match(created.id,/^[0-9a-f-]{36}$/);const again=await browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:f.payload});assert.equal(again.id,created.id);assert.equal(again.replayed,true);
assert.equal(f.store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(f.store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1); assert.equal(f.store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(f.store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1);
assert.equal(JSON.stringify(f.wire.filter(r=>r.route.includes('/channel/'))).includes(f.payload.payload.content),false);assert.equal(f.store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(f.payload.payload.content)),false); assert.equal(JSON.stringify(f.wire.filter(r=>r.route.includes('/channel/'))).includes(f.payload.payload.pageUrl),false);assert.equal(f.store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(f.payload.payload.pageUrl)),false);
const row=f.store.db.prepare('SELECT ciphertext FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(f.payload.payload.content)),false); const row=f.store.db.prepare('SELECT ciphertext FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(f.payload.payload.pageUrl)),false);
await assert.rejects(browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:{...f.payload,payload:{...f.payload.payload,content:'Different synthetic payload'}}}),e=>e.status===409); await assert.rejects(browser.protectedEvidenceRequest({device:f.device,request,save:()=>{},payload:{...f.payload,payload:{...f.payload.payload,bytes:17}}}),e=>e.status===409);
}); });
test('challenge consumption covers successful replay, invalid ciphertext and cross-device substitution',async t=>{ test('challenge consumption covers successful replay, invalid ciphertext and cross-device substitution',async t=>{
const f=await fixture(t);let a=await f.make();assert.equal((await f.api('/api/device/channel/evidence',a.context.request,'synthetic-pq-evidence-other-token-123456789')).status,403); const f=await fixture(t);let a=await f.make();assert.equal((await f.api('/api/device/channel/evidence',a.context.request,'synthetic-pq-evidence-other-token-123456789')).status,403);
+3 -2
View File
@@ -11,8 +11,8 @@ function fixture({evidence,review=true}={}){
} }
test('evidence controls default off and preserve unrelated draft fields',()=>{ test('evidence controls default off and preserve unrelated draft fields',()=>{
const {context,get}=fixture();assert.equal(get('evidence-policy-enabled').checked,false);assert.equal(get('evidence-policy-downloads').checked,false); const {context,get}=fixture();assert.equal(get('evidence-policy-enabled').checked,false);assert.equal(get('evidence-policy-downloads').checked,false);
assert.equal(get('evidence-policy-enabled').disabled,true);assert.equal(get('evidence-policy-downloads').disabled,true); assert.equal(get('evidence-policy-enabled').disabled,true);assert.equal(get('evidence-policy-downloads').disabled,true);assert.equal(get('evidence-policy-interactions').disabled,true);
const draft=context.draft();assert.equal(draft.version,4);assert.equal(draft.brand.name,'Company');assert.equal(draft.evidence.enabled,false);assert.equal(draft.evidence.captureDownloads,false); const draft=context.draft();assert.equal(draft.version,4);assert.equal(draft.brand.name,'Company');assert.equal(draft.evidence.enabled,false);assert.equal(draft.evidence.captureDownloads,false);assert.equal(draft.evidence.captureInteractions,false);
}); });
test('independent preparation permission enables draft controls; global off disables child control',()=>{ test('independent preparation permission enables draft controls; global off disables child control',()=>{
const {context,get,changes}=fixture({evidence:{enabled:true,captureDownloads:true}}); const {context,get,changes}=fixture({evidence:{enabled:true,captureDownloads:true}});
@@ -20,6 +20,7 @@ test('independent preparation permission enables draft controls; global off disa
context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,false);assert.equal(get('evidence-policy-downloads').disabled,false); context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,false);assert.equal(get('evidence-policy-downloads').disabled,false);
get('evidence-policy-enabled').checked=false;get('evidence-policy-enabled').change();assert.equal(get('evidence-policy-downloads').disabled,true);assert.equal(context.draft().evidence.enabled,false);assert.equal(changes(),1); get('evidence-policy-enabled').checked=false;get('evidence-policy-enabled').change();assert.equal(get('evidence-policy-downloads').disabled,true);assert.equal(context.draft().evidence.enabled,false);assert.equal(changes(),1);
context.fill({evidence:{enabled:true,captureDownloads:false}});assert.equal(get('evidence-policy-downloads').checked,false);assert.equal(get('evidence-policy-downloads').disabled,false); context.fill({evidence:{enabled:true,captureDownloads:false}});assert.equal(get('evidence-policy-downloads').checked,false);assert.equal(get('evidence-policy-downloads').disabled,false);
context.fill({evidence:{enabled:true,captureDownloads:false,captureInteractions:true}});assert.equal(get('evidence-policy-interactions').checked,true);assert.equal(get('evidence-policy-interactions').disabled,false);assert.equal(context.draft().evidence.captureInteractions,true);assert.equal(context.draft().evidence.captureDownloads,false);
}); });
test('direct publication mode requires policy.write rather than preparation permission',()=>{ test('direct publication mode requires policy.write rather than preparation permission',()=>{
const {context,get}=fixture({review:false});context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,true); const {context,get}=fixture({review:false});context.applyOperatorPermissions({permissions:['policy.prepare']});assert.equal(get('evidence-policy-enabled').disabled,true);
+1 -1
View File
@@ -8,7 +8,7 @@ import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os'; import {tmpdir} from 'node:os';
import {join} from 'node:path'; import {join} from 'node:path';
test('evidence controls default off, reject unknown settings and are bound to the policy signature',()=>{ test('evidence controls default off, reject unknown settings and are bound to the policy signature',()=>{
const p=defaultPolicy();assert.deepEqual(p.evidence,{enabled:false,captureDownloads:false});assert.equal(PolicySchema.safeParse({...p,evidence:{enabled:true,captureDownloads:true,fullFiles:true}}).success,false); const p=defaultPolicy();assert.deepEqual(p.evidence,{enabled:false,captureDownloads:false,captureInteractions:false});assert.equal(PolicySchema.safeParse({...p,evidence:{enabled:true,captureDownloads:true,fullFiles:true}}).success,false);
const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'});p.evidence={enabled:true,captureDownloads:true};const envelope=signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'}));assert.equal(verifyPolicy(envelope,publicKey).evidence.captureDownloads,true);p.evidence.enabled=false;envelope.payload=Buffer.from(JSON.stringify(p)).toString('base64url');assert.throws(()=>verifyPolicy(envelope,publicKey)); const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'});p.evidence={enabled:true,captureDownloads:true};const envelope=signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'}));assert.equal(verifyPolicy(envelope,publicKey).evidence.captureDownloads,true);p.evidence.enabled=false;envelope.payload=Buffer.from(JSON.stringify(p)).toString('base64url');assert.throws(()=>verifyPolicy(envelope,publicKey));
}); });
test('central policy publication cannot enable unconfigured evidence storage',async t=>{ test('central policy publication cannot enable unconfigured evidence storage',async t=>{
+44 -7
View File
@@ -1,11 +1,36 @@
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs'; import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,writeFileSync,readFileSync,rmSync} from 'node:fs';import {execFileSync} from 'node:child_process';import {tmpdir} from 'node:os';import {join} from 'node:path';import {randomBytes,randomUUID,createCipheriv} from 'node:crypto';import {openStore,digest} from '../apps/server/store.mjs';import {createApp} from '../apps/server/app.mjs';
function protectFixtureKey(file){ function protectFixtureKey(file){
if(process.platform!=='win32')return; if(process.platform!=='win32')return;
const script="$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_EVIDENCE_KEY -AclObject $acl"; const script="$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$acl=New-Object System.Security.AccessControl.FileSecurity;$acl.SetOwner($sid);$acl.SetAccessRuleProtection($true,$false);$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow')));Set-Acl -LiteralPath $env:TEST_EVIDENCE_KEY -AclObject $acl";
execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',script],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:file},stdio:'pipe'}); execFileSync(join(process.env.SystemRoot,'System32/WindowsPowerShell/v1.0/powershell.exe'),['-NoProfile','-NonInteractive','-Command',script],{windowsHide:true,env:{SystemRoot:process.env.SystemRoot,TEST_EVIDENCE_KEY:file},stdio:'pipe'});
} }
async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1});return {store,api,id,eventId,config};} async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});return {store,api,id,eventId,config};}
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic confidential engineering detail',sensitiveField:false}}); const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}});
test('interaction collection is independent and binds completed action, session and capture source',async t=>{
const {api,store,id,eventId}=await fixture(t),body=evidence(eventId),p=store.get('policy');
p.evidence.captureInteractions=false;store.put('policy',p);assert.equal((await api('/api/device/evidence','POST',body)).status,403);
p.evidence.captureInteractions=true;p.evidence.captureDownloads=false;store.put('policy',p);
for(const payload of [{...body.payload,sessionId:randomUUID()},{...body.payload,captureSource:'agent-adapter'},{...body.payload,outcome:'authorized'},{...body.payload,content:'any content'},{...body.payload,target:{tag:'BUTTON',label:'any label'}}])assert.equal((await api('/api/device/evidence','POST',{...body,payload})).status,400);
assert.equal((await api('/api/device/evidence','POST',body)).status,201);
for(const [action,outcome]of [['read','completed'],['click','authorized'],['click','failed']]){
const event=randomUUID();store.event(id,'agent.action',{eventId:event,ruleId:'click:completed',agent:{action,outcome,sessionId:body.payload.sessionId}});
assert.equal((await api('/api/device/evidence','POST',{eventId:event,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter'}})).status,400);
}
const click=randomUUID();store.event(id,'agent.action',{eventId:click,agent:{action:'click',outcome:'completed',sessionId:body.payload.sessionId}});
assert.equal((await api('/api/device/evidence','POST',{eventId:click,type:'interaction',payload:{...body.payload,action:'click',captureSource:'agent-adapter',target:{tag:'BUTTON',role:'button',x:10,y:20}}})).status,201);
const blocked=randomUUID();store.event(id,'clipboard.block',{eventId:blocked,interaction:{action:'copy',outcome:'denied',sessionId:body.payload.sessionId}});
assert.equal((await api('/api/device/evidence','POST',{...body,eventId:blocked})).status,400);
});
test('ordinary interaction audit facts require matching lifecycle and reject content',async t=>{
const {api,store}=await fixture(t),sessionId=evidence(randomUUID()).payload.sessionId;
const input={eventId:randomUUID(),kind:'clipboard.copy',policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId}};
assert.equal((await api('/api/device/events','POST',input)).status,200);
for(const interaction of [{...input.interaction,action:'paste'},{...input.interaction,outcome:'denied'},{...input.interaction,content:'secret'}])assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),interaction})).status,400);
assert.equal((await api('/api/device/events','POST',{...input,eventId:randomUUID(),kind:'clipboard.block',interaction:{...input.interaction,outcome:'denied'}})).status,200);
const events=store.db.prepare("SELECT metadata FROM events WHERE json_extract(metadata,'$.interaction') IS NOT NULL").all();assert.equal(events.some(row=>row.metadata.includes('secret')),false);
});
test('protected evidence cannot contradict source actions, decisions, hosts or file facts',async t=>{ test('protected evidence cannot contradict source actions, decisions, hosts or file facts',async t=>{
const {api,store,id,eventId}=await fixture(t); const {api,store,id,eventId}=await fixture(t);
assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,action:'paste'}})).status,400); assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,action:'paste'}})).status,400);
@@ -22,11 +47,11 @@ test('incident discovery lists only linked metadata without decrypting or releas
for(const role of ['operator','auditor'])assert.equal((await api(path,'GET',undefined,role)).status,403); for(const role of ['operator','auditor'])assert.equal((await api(path,'GET',undefined,role)).status,403);
const result=await api(path,'GET',undefined,'admin');assert.equal(result.status,200);assert.equal(result.body.incidentId,incident.body.id); const result=await api(path,'GET',undefined,'admin');assert.equal(result.status,200);assert.equal(result.body.incidentId,incident.body.id);
assert.equal((await api(path,'GET',undefined,'analyst')).status,200); assert.equal((await api(path,'GET',undefined,'analyst')).status,200);
const analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.content,evidence(eventId).payload.content); const analystRead=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(analystRead.status,200);assert.equal(analystRead.body.payload.pageUrl,evidence(eventId).payload.pageUrl);
const access=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(access.metadata).actor,'analyst'); const access=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(access.metadata).actor,'analyst');
assert.deepEqual(Object.keys(result.body.evidence[0]).sort(),['id','sourceEventId','eventId','type','createdAt','expiresAt','retentionHold','availability'].sort()); assert.deepEqual(Object.keys(result.body.evidence[0]).sort(),['id','sourceEventId','eventId','type','createdAt','expiresAt','retentionHold','availability'].sort());
assert.equal(result.body.evidence[0].id,created.body.id);assert.equal(result.body.evidence[0].eventId,eventId);assert.equal(result.body.evidence[0].availability,'available');assert.equal(result.body.evidence[0].retentionHold,true); assert.equal(result.body.evidence[0].id,created.body.id);assert.equal(result.body.evidence[0].eventId,eventId);assert.equal(result.body.evidence[0].availability,'available');assert.equal(result.body.evidence[0].retentionHold,true);
assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.content),false);assert.equal(store.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='evidence.accessed'").get().n,1); assert.equal(JSON.stringify(result.body).includes(evidence(eventId).payload.pageUrl),false);assert.equal(store.db.prepare("SELECT COUNT(*) n FROM events WHERE kind='evidence.accessed'").get().n,1);
// A damaged ciphertext does not prevent safe discovery; reveal still authenticates. // A damaged ciphertext does not prevent safe discovery; reveal still authenticates.
store.db.prepare('UPDATE protected_evidence SET ciphertext=?').run(Buffer.from('synthetic corruption')); store.db.prepare('UPDATE protected_evidence SET ciphertext=?').run(Buffer.from('synthetic corruption'));
assert.equal((await api(path,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500); assert.equal((await api(path,'GET',undefined,'admin')).status,200);assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin')).status,500);
@@ -49,7 +74,7 @@ test('recognizable credentials in free text, target labels, filenames and encode
assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0); assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);
assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,201); assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,201);
}); });
test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.content)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.content,body.payload.content);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.content)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,200);}); test('encrypted evidence only decrypts for explicit admin permission and audited purpose',async t=>{const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body);assert.equal(created.status,201);const row=store.db.prepare('SELECT * FROM protected_evidence').get();assert.equal(Buffer.from(row.ciphertext).includes(Buffer.from(body.payload.pageUrl)),false);for(const role of ['operator','auditor'])assert.equal((await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,role)).status,403);assert.equal((await api(`/api/admin/evidence/${created.body.id}`,'GET',undefined,'admin')).status,400);const got=await api(`/api/admin/evidence/${created.body.id}?reason=incident-response`,'GET',undefined,'admin');assert.equal(got.status,200);assert.equal(got.body.payload.pageUrl,body.payload.pageUrl);const audit=store.db.prepare("SELECT metadata FROM events WHERE kind='evidence.accessed'").get();assert.equal(JSON.parse(audit.metadata).reason,'incident-response');assert.equal(store.db.prepare('SELECT metadata FROM events').all().some(r=>r.metadata.includes(body.payload.pageUrl)),false);assert.equal((await api('/api/device/evidence','POST',body)).status,200);});
test('unknown credentials, password fields, token fields, unowned events and credential URLs reject',async t=>{const {api,eventId}=await fixture(t);for(const extra of [{password:'synthetic'},{authorization:'Bearer synthetic'},{sensitiveField:true},{pageUrl:'https://user:[email protected]/'},{pageUrl:'https://example.test/?token=secret'}])assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),payload:{...evidence(eventId).payload,...extra}})).status,400);assert.equal((await api('/api/device/evidence','POST',evidence(randomUUID()))).status,404);assert.equal((await api('/api/device/evidence','POST',{...evidence(eventId),type:'download'})).status,400);}); test('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('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('audit failure rolls back evidence and prevents plaintext response',async t=>{const {api,store,eventId}=await fixture(t),emit=store.event;store.event=()=>{throw Error('synthetic audit failure');};assert.equal((await api('/api/device/evidence','POST',evidence(eventId))).status,500);assert.equal(store.db.prepare('SELECT COUNT(*) n FROM protected_evidence').get().n,0);store.event=emit;const created=await api('/api/device/evidence','POST',evidence(eventId));store.event=()=>{throw Error('synthetic audit failure');};const got=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'admin');assert.equal(got.status,500);assert.equal(got.body.payload,undefined);store.event=emit;});
@@ -65,6 +90,18 @@ test('centrally disabled and expired evidence policies reject new capture while
}); });
test('exact evidence replay is idempotent but a changed payload remains a conflict',async t=>{ test('exact evidence replay is idempotent but a changed payload remains a conflict',async t=>{
const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body),replayed=await api('/api/device/evidence','POST',body);assert.equal(replayed.status,200);assert.equal(replayed.body.id,created.body.id);assert.equal(replayed.body.replayed,true); const {api,store,eventId}=await fixture(t),body=evidence(eventId),created=await api('/api/device/evidence','POST',body),replayed=await api('/api/device/evidence','POST',body);assert.equal(replayed.status,200);assert.equal(replayed.body.id,created.body.id);assert.equal(replayed.body.replayed,true);
assert.equal((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,content:'Different synthetic content'}})).status,409); assert.equal((await api('/api/device/evidence','POST',{...body,payload:{...body.payload,bytes:17}})).status,409);
assert.equal(store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1); assert.equal(store.db.prepare('SELECT count(*) n FROM protected_evidence').get().n,1);assert.equal(store.db.prepare("SELECT count(*) n FROM events WHERE kind='evidence.recorded'").get().n,1);
}); });
// Migration must keep already encrypted, authorized historical records readable.
test('historical interaction content remains readable but cannot be submitted as new capture',async t=>{
const {api,store,eventId,config}=await fixture(t),created=await api('/api/device/evidence','POST',evidence(eventId));
const row=store.db.prepare('SELECT * FROM protected_evidence').get(),legacy={pageUrl:'https://intranet.example/document',action:'copy',content:'Synthetic historical evidence',sensitiveField:false};
const key=readFileSync(config.evidenceKeyFile),nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);
cipher.setAAD(Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at])));
const plain=Buffer.from(JSON.stringify(legacy)),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);key.fill(0);
store.db.prepare('UPDATE protected_evidence SET nonce=?,ciphertext=?,tag=? WHERE id=?').run(nonce,encrypted,cipher.getAuthTag(),row.id);
const revealed=await api(`/api/admin/evidence/${created.body.id}?reason=investigation`,'GET',undefined,'analyst');assert.equal(revealed.status,200);assert.deepEqual(revealed.body.payload,legacy);
assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:legacy})).status,400);
});
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url);
const {buildInteractionEvidence,validateInteractionEvidence}=require('../apps/browser/evidence-capture.cjs');
const {collectionAllowed,stripPendingEvidence,deliverAuditRecord,validatedEvidence}=require('../apps/browser/evidence-delivery.cjs');
const sessionId='00000000-0000-4000-8000-000000000001';
function fixture(){const context={ready:true,deviceId:'device-a',policy:{expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureDownloads:true,captureInteractions:true}}};const payload=buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com/private/password=secret?q=secret#secret',sensitiveField:false,outcome:'completed',captureSource:'guarded-clipboard',bytes:12},context.policy);const record={eventId:sessionId,kind:'clipboard.copy',protectedEvidence:{deviceId:'device-a',type:'interaction',payload}};return {context,payload,record};}
test('automatic interaction capture requires observed completion and excludes path and sensitive data',()=>{const f=fixture();assert.equal(f.payload.pageUrl,'https://example.com/');assert.equal(validateInteractionEvidence(f.payload),true);for(const extra of [{content:'secret'},{contentSha256:'a'.repeat(64)},{target:{tag:'INPUT',label:'secret'}},{sensitiveField:true},{outcome:'authorized'},{captureSource:'page'},{bytes:16777217}])assert.equal(validateInteractionEvidence({...f.payload,...extra}),false);assert.equal(buildInteractionEvidence({action:'copy',sessionId,url:'https://example.com',sensitiveField:false,outcome:'authorized',captureSource:'guarded-clipboard'},f.context.policy),null);});
test('type binding rejects cross-type payloads without attempting evidence send',async()=>{const f=fixture();assert.throws(()=>validatedEvidence(f.payload,'download'),/Invalid/);f.record.protectedEvidence.type='download';const calls=[];await assert.rejects(deliverAuditRecord({record:f.record,getContext:()=>f.context,send:async route=>{calls.push(route);return {};}}),/Invalid/);assert.deepEqual(calls,['/api/device/events']);});
test('partial disable discards interaction tuples while preserving pending legacy downloads',()=>{const f=fixture();f.context.policy.evidence.captureInteractions=false;assert.equal(collectionAllowed({...f.context,type:'interaction'}),false);assert.equal(collectionAllowed({...f.context,type:'download'}),true);const download={eventId:'download',protectedEvidence:{deviceId:'device-a',payload:{}}};const result=stripPendingEvidence([f.record,download],f.context);assert.equal('protectedEvidence' in result[0],false);assert.equal(result[1],download);assert.ok(f.record.protectedEvidence);});
test('revocation during ACK never marks evidence delivered and replay preserves original tuple',async()=>{const f=fixture(),snapshot=structuredClone(f.record),bodies=[];const send=async(route,body)=>{bodies.push(body);if(route.endsWith('/evidence')){f.context.policy.evidence.captureInteractions=false;return {id:sessionId,sourceEventId:1};}return {};};assert.deepEqual(await deliverAuditRecord({record:f.record,getContext:()=>f.context,send}),{eventDelivered:true,evidenceDiscarded:true});assert.deepEqual(f.record,snapshot);assert.equal(bodies[1].type,'interaction');assert.equal('protectedEvidence' in bodies[0],false);});
test('native hook target tags are canonicalized without accepting labels or arbitrary DOM data',()=>{const f=fixture(),base={action:'click',sessionId,url:'https://example.com/page',sensitiveField:false,outcome:'completed',captureSource:'agent-adapter'};for(const tag of ['a','button','input','textarea','select','div','span','p','body','unknown']){const value=buildInteractionEvidence({...base,target:{tag,role:'menuitem',x:12,y:34}},f.context.policy);assert.equal(value.target.tag,tag.toUpperCase());assert.equal(value.target.role,'menuitem');assert.deepEqual([value.target.x,value.target.y],[12,34]);}for(const target of [{tag:'button',label:'secret'},{tag:'a',selector:'#secret'},{tag:'body',text:'secret'},{tag:'script'},{tag:'button',x:Infinity},{tag:'button',y:-1}])assert.equal(buildInteractionEvidence({...base,target},f.context.policy),null);assert.equal(buildInteractionEvidence({...base,target:{tag:'select',role:'option'}},f.context.policy).target.role,'option');});
+7
View File
@@ -0,0 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {toECS,toCEF} from '../packages/core/siem.mjs';
const sessionId='00000000-0000-4000-8000-000000000001';
function record(outcome){return {id:1,time:'2026-10-13T12:00:00Z',kind:outcome==='denied'?'clipboard.block':'clipboard.copy',device_id:'device-a',metadata:{interaction:{action:'copy',outcome,sessionId},ruleId:'clipboard-policy',policyVersion:2,traceId:'1'.repeat(32),spanId:'2'.repeat(16)},audit:{hash:'3'.repeat(64),sequence:1}};}
test('ECS and CEF retain interaction phase/session while preserving trace and audit references',()=>{for(const outcome of ['authorized','completed','denied']){const e=record(outcome),ecs=toECS(e),cef=toCEF(e);assert.deepEqual(ecs.safetynet.interaction,{action:'copy',outcome,session_id:sessionId});assert.equal(ecs.trace.id,'1'.repeat(32));assert.ok(cef.includes('cs2Label=InteractionAction cs2=copy'));assert.ok(cef.includes('cs3Label=InteractionOutcome cs3='+outcome));assert.ok(cef.includes('requestContext='+sessionId));assert.ok(cef.includes('flexString1Label=AuditRecordHash'));assert.ok(cef.includes('cs4Label=TraceId'));}});
test('malformed or contradictory interaction metadata never enters security exports',()=>{for(const value of [{action:'paste',outcome:'completed',sessionId},{action:'copy',outcome:'completed',sessionId,content:'PRIVATE_BODY'},{action:'copy',outcome:'completed',sessionId:'private-session'}]){const e=record('completed');e.metadata.interaction=value;assert.equal(toECS(e).safetynet.interaction,undefined);assert.equal(toCEF(e).includes('InteractionAction'),false);assert.equal(JSON.stringify(toECS(e)).includes('PRIVATE_BODY'),false);}});
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../scripts/native-tabs-smoke.mjs',import.meta.url),'utf8'),c=vm.createContext({});
vm.runInContext(source.slice(source.indexOf('function exportCountsByService'),source.indexOf('const diagnosticStatus')),c);
const resource=name=>({resource:{attributes:[{key:'service.name',value:{stringValue:name}}]}});
test('shared native receiver distinguishes actual browser exports from backend batches',()=>{
const records=[{payload:{resourceSpans:[resource('safetynet-browser')]}},{payload:{resourceLogs:[resource('safetynet-browser')]}},{payload:{resourceSpans:[resource('safetynet-control-plane')]}}];
assert.deepEqual({...c.exportCountsByService(records)},{browser:2,controlPlane:1,unknown:0});
records.push({payload:{resourceSpans:[resource('safetynet-control-plane')]}});assert.deepEqual({...c.exportCountsByService(records)},{browser:2,controlPlane:2,unknown:0});
records.push({payload:{resourceMetrics:[resource('safetynet-browser'),resource('safetynet-browser')]}});assert.equal(c.exportCountsByService(records).browser,3,'Count transport requests, not resource groups');
});
test('unattributed and mixed batches cannot disappear from collector accounting',()=>{
assert.deepEqual({...c.exportCountsByService([{payload:{}},{payload:{resourceSpans:[resource('unexpected')]}},{payload:{resourceSpans:[resource('safetynet-browser'),resource('safetynet-control-plane')]}}])},{browser:1,controlPlane:1,unknown:2});
});
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {createServer} from 'node:http';
import {generateKeyPairSync} from 'node:crypto';
import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
import {signBookmarkSnapshot,verifyBookmarkSnapshot} from '../packages/core/bookmarks.mjs';
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs');
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
// Substitute only the dynamic module-loader boundary; execute the actual shipped
// application function and the real OTLP diagnostics helper.
const extracted=source.slice(source.indexOf('function applyPolicy(envelope)'),source.indexOf('async function sync()')).replace("import('../../packages/core/bookmarks.mjs')","Promise.resolve(bookmarkModule)");
async function fixture(t){
const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));
const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000});
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config);
const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001';
const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)});
const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});};
const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])};
}
test('actual unchanged policy exports correlated verification, bookmark and agent child stages',async t=>{
const f=await fixture(t);await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();const spans=f.spans(),parent=spans.find(s=>!s.parentSpanId);assert.equal(parent.name,'browser.policy');
for(const operation of ['policy.verify','policy.bookmarks','policy.agent-reconcile']){const child=spans.find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(child,operation);assert.equal(child.traceId,parent.traceId);assert.equal(child.parentSpanId,parent.spanId);assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));}
assert.equal(JSON.stringify(f.payloads).includes('fixture'),false);
});
test('new signed policy disables diagnostics throughout network transition and honors final disable',async t=>{
const f=await fixture(t);f.c.policy={...f.next,version:0};f.next.diagnostics={enabled:false};await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,false,name);
assert.deepEqual(f.calls.filter(v=>v.name==='configure').map(v=>v.enabled),[false,false]);assert.equal(f.diagnostics.status().enabled,false);assert.equal(f.spans().length,0,'Retired old generation must not export');
});
test('enabled new policy exports only post-configuration stages after protected network transition',async t=>{
const f=await fixture(t);f.c.policy={...f.next,version:0};await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
assert.equal(f.calls.find(v=>v.name==='browser-proxy').enabled,false);assert.equal(f.calls.find(v=>v.name==='page').enabled,true);
const operations=f.spans().map(s=>s.attributes.find(a=>a.key==='safetynet.operation')?.value.stringValue);assert.deepEqual(operations.sort(),['policy.page-configure','policy.agent-reconcile','policy.audit'].sort());
});
test('signed verification failure prevents bookmark, proxy and policy mutations',async t=>{
const f=await fixture(t);const tampered=f.envelope();tampered.payload+='A';await assert.rejects(f.c.applyPolicy(tampered),/signature rejected/);await f.diagnostics.flush();assert.equal(f.calls.length,0);assert.equal(f.c.policy.version,1);assert.ok(f.spans().some(s=>s.status.code===2));
});