Capture guarded human gestures and verify agent administration
This commit is contained in:
1 parent
244f01630b
commit
adf0940e5f
32 files changed
+523
-52
No files matched your search
@@ -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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay 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. |
|
||||
|
||||
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).
|
||||
@@ -178,7 +178,7 @@ The packaging workflow produces an unpacked evaluation build in `dist/win-unpack
|
||||
|
||||
An Electron shell does not yet provide complete enterprise DLP across every transport. [Ordinary Windows NTFS file uploads](docs/windows-file-uploads.md) have a tested handle-bound release path; unsupported ranges, reparse/network paths, data pipes and upload redirects remain blocked. WebSocket/WebRTC/streaming, encoded or covert channels, printing, OS screen capture, other applications and local administrator tampering are not fully covered. Complete audio/video transcription and frame coverage are unfinished.
|
||||
|
||||
Automatic forensic browser capture, a complete analyst evidence interface, per-agent cryptographic identities, SCIM, production key rotation, tested backup/recovery and independent security testing remain open. An allow decision means no implemented rule matched the supported input; it does not guarantee absence of secrets or malware.
|
||||
Full forensic browser replay, a complete analyst evidence interface, executable/device cryptographic identity, SCIM, production key rotation, tested backup/recovery and independent security testing remain open. Separate agent credentials and bounded metadata capture are implemented; their Windows same-user trust boundary remains. An allow decision means no implemented rule matched the supported input; it does not guarantee absence of secrets or malware.
|
||||
|
||||
See the [threat model](docs/threat-model.md) and [requirements audit and roadmap](docs/requirements-audit.md) for coverage and remaining gates. Report security concerns privately to the repository owner; never post credentials or confidential documents in a public issue.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
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, agent clicks and observed foreground click/form gestures using bounded metadata. An observed form event does not prove transmission. 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.
|
||||
@@ -9,9 +9,9 @@ const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs'
|
||||
const {resourceFromAttributes}=require('@opentelemetry/resources');
|
||||
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=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','browser.evidence']);
|
||||
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 operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','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 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']);
|
||||
@@ -61,8 +61,26 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Durable events may be delivered by a flusher started under another event's
|
||||
// async context. Adopt only data-property IDs; never inherit that unrelated
|
||||
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
|
||||
function withCorrelation(ids,fn){
|
||||
if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');
|
||||
const current=generation;let parent=null;
|
||||
try{
|
||||
if(current?.active&&ids&&typeof ids==='object'&&!Array.isArray(ids)&&[Object.prototype,null].includes(Object.getPrototypeOf(ids))){
|
||||
const descriptors=Object.getOwnPropertyDescriptors(ids),traceId=descriptors.traceId?.value,spanId=descriptors.spanId?.value;
|
||||
if(typeof traceId==='string'&&/^[a-f0-9]{32}$/.test(traceId)&&traceId!=='0'.repeat(32)&&typeof spanId==='string'&&/^[a-f0-9]{16}$/.test(spanId)&&spanId!=='0'.repeat(16)){
|
||||
const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;
|
||||
const sampled=randomInt(1000000)<Math.floor(rate*1000000);
|
||||
parent={generation:current,span:trace.wrapSpanContext({traceId,spanId,traceFlags:sampled?TraceFlags.SAMPLED:TraceFlags.NONE,isRemote:false})};
|
||||
}
|
||||
}
|
||||
}catch{/* Invalid diagnostic metadata cannot affect protected delivery. */}
|
||||
return storage.run(parent,fn);
|
||||
}
|
||||
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]);}
|
||||
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})};
|
||||
return {configure,span,event,importStages,withCorrelation,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};
|
||||
@@ -25,7 +25,7 @@ function validateInteractionEvidence(value){
|
||||
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;
|
||||
if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||!['completed','observed'].includes(outcome))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;
|
||||
@@ -35,7 +35,7 @@ function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,ou
|
||||
}
|
||||
// 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})};
|
||||
const payload={pageUrl:page.origin+'/',action,sessionId,outcome,sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})};
|
||||
return validateInteractionEvidence(payload)?structuredClone(payload):null;
|
||||
}
|
||||
module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence};
|
||||
@@ -13,7 +13,7 @@ async function deliverAuditRecord({record,send,getContext}){
|
||||
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,type);
|
||||
if(type==='interaction'){
|
||||
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:'agent.action';
|
||||
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:payload.captureSource==='human-observer'?'browser.interaction':'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});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
// Runs only in the privileged isolated world; no preload IPC or page-world bridge.
|
||||
function humanInteractionDOM(mode,url,token){
|
||||
const key='__safetynetHumanInteraction';
|
||||
const previous=globalThis[key];
|
||||
if(mode==='dispose'){if(previous?.token===token){previous.dispose();delete globalThis[key];}return null;}
|
||||
if(window.top!==window||location.href!==url)return null;
|
||||
if(mode==='drain'){
|
||||
if(!previous||previous.token!==token)return null;
|
||||
previous.until=Date.now()+1500;const events=previous.queue.splice(0,16);return {events,dropped:previous.dropped};
|
||||
}
|
||||
if(mode!=='install')return null;
|
||||
if(previous){previous.dispose();delete globalThis[key];}
|
||||
const state={token,queue:[],dropped:0,until:Date.now()+1500,tokens:20,last:Date.now(),gesture:null};
|
||||
const sensitive=e=>{
|
||||
let steps=0;
|
||||
for(let p=e;p;p=p.parentElement){
|
||||
if(++steps>64)return true;
|
||||
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(' '))||/^(password|file|hidden)$/i.test(p.type||''))return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const formSensitive=e=>{
|
||||
const form=e?.tagName==='FORM'?e:e?.form||e?.closest?.('form');
|
||||
if(!form)return false;
|
||||
const controls=form.elements;if(!controls||controls.length>128)return true;
|
||||
for(let i=0;i<controls.length;i++)if(sensitive(controls[i]))return true;
|
||||
return false;
|
||||
};
|
||||
const record=event=>{
|
||||
const now=Date.now();if(event.isTrusted!==true||now>state.until||location.href!==url||event.type==='submit'&&(navigator.userActivation?.isActive!==true||state.gesture===null||now-state.gesture>1000))return;
|
||||
const target=event.target?.nodeType===1?event.target:event.target?.parentElement;
|
||||
if(!target?.isConnected||sensitive(target)||formSensitive(target))return;
|
||||
if(event.type==='click')state.gesture=now;
|
||||
state.tokens=Math.min(20,state.tokens+Math.max(0,now-state.last)*0.01);state.last=now;
|
||||
if(state.tokens<1||state.queue.length>=16){state.dropped=Math.min(1000000,state.dropped+1);return;}
|
||||
state.tokens--;
|
||||
const tag=['A','BUTTON','INPUT','TEXTAREA','SELECT','DIV','SPAN','P','BODY'].includes(target.tagName)?target.tagName:'OTHER';
|
||||
const facts={tag};const role=target.getAttribute?.('role');
|
||||
if(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option'].includes(role))facts.role=role;
|
||||
if(event.type==='click'&&Number.isFinite(event.clientX)&&Number.isFinite(event.clientY)&&event.clientX>=0&&event.clientY>=0&&event.clientX<=innerWidth&&event.clientY<=innerHeight){facts.x=Math.round(event.clientX);facts.y=Math.round(event.clientY);}
|
||||
state.queue.push({action:event.type,outcome:'observed',sensitiveField:false,target:facts});
|
||||
};
|
||||
const enter=event=>{if(event.isTrusted===true&&event.key==='Enter'&&Date.now()<=state.until&&location.href===url&&!sensitive(event.target)&&!formSensitive(event.target))state.gesture=Date.now();};
|
||||
window.addEventListener('click',record,true);window.addEventListener('submit',record,true);window.addEventListener('keydown',enter,true);
|
||||
state.dispose=()=>{window.removeEventListener('click',record,true);window.removeEventListener('submit',record,true);window.removeEventListener('keydown',enter,true);state.queue.length=0;};
|
||||
globalThis[key]=state;return {installed:true};
|
||||
}
|
||||
function humanInteractionScript(mode,{url,token}){
|
||||
if(!['install','drain','dispose'].includes(mode)||typeof url!=='string'||url.length>4096||typeof token!=='string'||!/^[a-f0-9]{32,64}$/.test(token))throw TypeError('Invalid interaction observer context');
|
||||
return `(${humanInteractionDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)})`;
|
||||
}
|
||||
module.exports={humanInteractionScript};
|
||||
|
||||
function createHumanInteractionObserver({getContext,isCurrent,executeScript,onObserved,onDropped=()=>{},interval=250,deadline=1000}){
|
||||
if([getContext,isCurrent,executeScript,onObserved,onDropped].some(fn=>typeof fn!=='function'))throw TypeError('Invalid interaction observer callbacks');
|
||||
if(!Number.isSafeInteger(interval)||interval<100||interval>500||!Number.isSafeInteger(deadline)||deadline<100||deadline>1000)throw TypeError('Invalid interaction observer timing');
|
||||
const {randomBytes}=require('node:crypto');let current=null,retired=null,busy=false,closed=false,epoch=0;
|
||||
const eligible=c=>!!c&&c.ready===true&&c.enabled===true&&c.foreground===true&&typeof c.sessionId==='string'&&typeof c.url==='string'&&typeof c.token==='string'&&Number.isSafeInteger(c.policyVersion)&&c.webContents&&!c.webContents.isDestroyed?.()&&/^https?:\/\//.test(c.url)&&isCurrent(c);
|
||||
const same=(a,b)=>a&&b&&a.sessionId===b.sessionId&&a.url===b.url&&a.token===b.token&&a.policyVersion===b.policyVersion&&a.webContents===b.webContents;
|
||||
const validBatch=result=>result&&typeof result==='object'&&!Array.isArray(result)&&Object.keys(result).every(k=>['events','dropped'].includes(k))&&Array.isArray(result.events)&&result.events.length<=16&&Number.isSafeInteger(result.dropped)&&result.dropped>=0&&result.dropped<=1000000;
|
||||
async function tick(){
|
||||
if(closed||busy)return;busy=true;const generation=epoch;let timedOut=false;const timer=setTimeout(()=>{timedOut=true;epoch++;current=null;},deadline);timer.unref?.();
|
||||
try{
|
||||
if(retired){const previous=retired;retired=null;await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');if(closed||timedOut||generation!==epoch)return;}
|
||||
const context=getContext();
|
||||
if(!eligible(context)){const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');return;}
|
||||
if(!same(current?.context,context)){
|
||||
const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');
|
||||
if(closed||timedOut||generation!==epoch||!eligible(context))return;
|
||||
const secret=randomBytes(24).toString('hex');const result=await executeScript(context.webContents,humanInteractionScript('install',{url:context.url,token:secret}),'install');
|
||||
if(!closed&&!timedOut&&generation===epoch&&eligible(context)&&result?.installed===true)current={context,secret,dropped:0};
|
||||
else if(result?.installed===true)await executeScript(context.webContents,humanInteractionScript('dispose',{url:context.url,token:secret}),'dispose');return;
|
||||
}
|
||||
const selected=current;const result=await executeScript(context.webContents,humanInteractionScript('drain',{url:context.url,token:selected.secret}),'drain');
|
||||
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context)||!validBatch(result))return;
|
||||
if(result.dropped>selected.dropped){await onDropped(result.dropped-selected.dropped,context);selected.dropped=result.dropped;}
|
||||
for(const event of result.events){
|
||||
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context))return;
|
||||
// Never trust arbitrary renderer keys; the root validates the shared evidence schema too.
|
||||
if(!event||typeof event!=='object'||Array.isArray(event)||Object.keys(event).some(k=>!['action','outcome','sensitiveField','target'].includes(k))||!['click','submit'].includes(event.action)||event.outcome!=='observed'||event.sensitiveField!==false)continue;
|
||||
await onObserved(event,context);
|
||||
}
|
||||
}catch{current=null;}finally{clearTimeout(timer);busy=false;}
|
||||
}
|
||||
const timer=setInterval(()=>{void tick();},interval);timer.unref?.();
|
||||
function refresh(){epoch++;if(current)retired=current;current=null;void tick();}
|
||||
function dispose(){if(closed)return;closed=true;epoch++;clearInterval(timer);const previous=current||retired;current=null;retired=null;if(previous)Promise.resolve().then(()=>executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose')).catch(()=>{});}
|
||||
return {refresh,dispose,poll:tick};
|
||||
}
|
||||
module.exports.createHumanInteractionObserver=createHumanInteractionObserver;
|
||||
+27
-3
@@ -203,7 +203,7 @@ function enforceEvidencePolicy(){
|
||||
|
||||
async function flushAudit(){
|
||||
if(!device||flushing||!auditQueue.length)return;flushing=true;
|
||||
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++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}}
|
||||
catch{}finally{flushing=false;}
|
||||
}
|
||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
|
||||
@@ -567,15 +567,39 @@ function hooks(){
|
||||
});
|
||||
// Session request, header and permission gates are installed exactly once.
|
||||
}
|
||||
function interactionObserverContext(tab){
|
||||
const contents=tab.contents||tab.view.webContents,nativeContext=tabContext(tab.id);
|
||||
return {ready:ready&¤tTabContext(nativeContext),enabled:policy?.evidence?.enabled===true&&policy.evidence.captureInteractions===true,policyVersion:policy?.version||0,sessionId:tab.id,url:contents.getURL(),token:`${tab.security.interactionDocumentGeneration}:${nativeContext.activation}`,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,webContents:contents,nativeContext,documentGeneration:tab.security.interactionDocumentGeneration};
|
||||
}
|
||||
function currentInteractionObserverContext(context){
|
||||
const tab=activeTab();
|
||||
if(!context||!ready||!tab||policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||context.policyVersion!==policy.version||Date.parse(policy.expiresAt)<=Date.now()||context.sessionId!==tab.id||context.webContents!==tab.contents||tab.contents.isDestroyed()||context.url!==tab.contents.getURL()||context.documentGeneration!==tab.security.interactionDocumentGeneration||tab.startPage||overviewPanel||toolsOpen)return false;
|
||||
const live=tabs.context(tab.id);
|
||||
return !live.paused&&live.activation===context.nativeContext.activation;
|
||||
}
|
||||
function installSiteHooks(tab){
|
||||
const contents=tab.view.webContents,security=tab.security;
|
||||
security.interactionDocumentGeneration=0;
|
||||
security.interactionObserver=require('./human-interaction.cjs').createHumanInteractionObserver({
|
||||
getContext:()=>interactionObserverContext(tab),isCurrent:currentInteractionObserverContext,
|
||||
executeScript:(target,code,operation)=>diagnosticSpan('browser.evidence',{operation:`evidence.observer.${operation}`,sessionId:tab.id},()=>target.executeJavaScriptInIsolatedWorld(998,[{code}],false)),
|
||||
onObserved:(event,context)=>diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:context.sessionId},async()=>{
|
||||
if(!currentInteractionObserverContext(context))return;
|
||||
const payload=require('./evidence-capture.cjs').buildInteractionEvidence({...event,sessionId:context.sessionId,url:context.url,captureSource:'human-observer'},policy);
|
||||
if(!payload||!currentInteractionObserverContext(context))return;
|
||||
await audit('browser.interaction',undefined,new URL(payload.pageUrl).hostname,undefined,undefined,payload,undefined,{action:payload.action,sessionId:payload.sessionId,outcome:'observed'});
|
||||
}),
|
||||
onDropped:async(count,context)=>{if(currentInteractionObserverContext(context)){diagnostics.event('browser.evidence',diagnosticContext({operation:'evidence.capture',sessionId:context.sessionId,count,status:'error'}));await audit('browser.interaction.dropped','interaction-capture-bounded');}}
|
||||
});
|
||||
contents.on('did-finish-load',()=>security.interactionObserver?.refresh());
|
||||
contents.once('destroyed',()=>security.interactionObserver?.dispose());
|
||||
const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}});
|
||||
security.developerToolsGuard=developerTools;
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url,agentAuthority:tab.security.agentAuthority}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy)||(tab.security.agentAuthority&&!actorDestinationAllowed(url,tab.security.agentAuthority)))event.preventDefault();});
|
||||
contents.on('will-attach-webview',event=>event.preventDefault());
|
||||
contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){if(!_inPlace){security.interactionDocumentGeneration++;security.interactionObserver?.refresh();}tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit());
|
||||
contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();});
|
||||
installClipboardControls(contents);
|
||||
@@ -643,7 +667,7 @@ app.whenReady().then(async()=>{
|
||||
Menu.setApplicationMenu(null);
|
||||
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
||||
win.removeMenu();
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)});
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>{tab.security.interactionObserver?.dispose();disposePageClipboardGuard(tab.contents);}});
|
||||
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
||||
clipboardGuard=createClipboardGuard({
|
||||
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
||||
|
||||
@@ -5,7 +5,7 @@ const {randomUUID}=require('node:crypto');
|
||||
function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) {
|
||||
if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry');
|
||||
const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false;
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.contents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();};
|
||||
function create({group=''}={}) {
|
||||
if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached');
|
||||
@@ -13,31 +13,32 @@ function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{
|
||||
const view=createView(),contents=view?.webContents;
|
||||
if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view');
|
||||
const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}};
|
||||
Object.defineProperty(tab,'contents',{value:contents,enumerable:false,writable:false,configurable:false});
|
||||
view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab);
|
||||
if(!activeId)activate(tab.id);return tab;
|
||||
}
|
||||
function activate(id) {
|
||||
const next=requireTab(id);if(id===activeId)return next;
|
||||
const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);}
|
||||
const previous=tabs.get(activeId);if(previous){if(!previous.contents.isDestroyed())previous.view.setVisible(false);detach(previous.view);}
|
||||
activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next;
|
||||
}
|
||||
function remove(id) {
|
||||
const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.view.webContents.id);
|
||||
if(wasActive){detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&tabs.size)activate(tabs.keys().next().value);
|
||||
const tab=tabs.get(id);if(!tab)throw Error('Unknown native tab');const wasActive=id===activeId;invalidate(tab);if(!tab.contents.isDestroyed())tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.contents.id);
|
||||
if(wasActive){if(!tab.contents.isDestroyed())detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{if(!tab.contents.isDestroyed())tab.contents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&!closed){const next=[...tabs.values()].find(item=>!item.contents.isDestroyed());if(next)activate(next.id);}
|
||||
}
|
||||
function context(id=activeId,{foreground=true}={}) {
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.contents,navigationId:tab.navigationId,url:tab.contents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
}
|
||||
function current(token,{expectedURL=token?.url}={}) {
|
||||
const tab=token&&tabs.get(token.tabId);
|
||||
return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
return !!tab&&!paused&&!token.paused&&!tab.contents.isDestroyed()&&tab.contents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.contents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
}
|
||||
function quarantine() {
|
||||
paused=true;activation++;
|
||||
for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();}
|
||||
for(const tab of tabs.values()){invalidate(tab);if(!tab.contents.isDestroyed()){tab.contents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.contents.closeDevTools?.();}}
|
||||
}
|
||||
function resume(version) {
|
||||
if(!Number.isSafeInteger(version)||version<1||version<policyVersion)throw Error('Invalid tab policy generation');
|
||||
@@ -49,6 +50,6 @@ function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{
|
||||
fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()],
|
||||
navigation:id=>invalidate(requireTab(id)),
|
||||
close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);},
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})};
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.contents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.contents.navigationHistory?.canGoBack()||false,canGoForward:tab.contents.navigationHistory?.canGoForward()||false}))})};
|
||||
}
|
||||
module.exports={createTabRegistry};
|
||||
+2
-2
@@ -108,8 +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});
|
||||
});
|
||||
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(),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'});
|
||||
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['browser.interaction','browser.interaction.dropped','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 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.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'});
|
||||
|
||||
@@ -37,11 +37,14 @@ function includesCredential(payload){
|
||||
function sourceMatches(source,payload,type){
|
||||
let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;}
|
||||
if(type==='interaction'){
|
||||
if(payload.outcome!=='completed'||!payload.sessionId)return false;
|
||||
if(source.kind==='agent.action'){
|
||||
if(!payload.sessionId)return false;
|
||||
if(source.kind==='browser.interaction'){
|
||||
if(payload.captureSource!=='human-observer'||payload.outcome!=='observed'||!['click','submit'].includes(payload.action)||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='observed'||metadata.interaction?.sessionId!==payload.sessionId)return false;
|
||||
}else if(source.kind==='agent.action'){
|
||||
if(payload.outcome!=='completed')return false;
|
||||
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(payload.outcome!=='completed'||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;
|
||||
@@ -71,7 +74,7 @@ export function installEvidence(app,store,config,device,admin){
|
||||
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=(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 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'});
|
||||
const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action','browser.interaction'];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'});
|
||||
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{}
|
||||
|
||||
@@ -330,7 +330,7 @@ async function reviewHistory(version){
|
||||
|
||||
const comparison=await api(`/api/admin/policy/diff?from=${version}&to=${current.currentVersion}`);if(!Array.isArray(comparison.changes)||!Array.isArray(comparison.groupDiff?.changes)||!Array.isArray(comparison.profileDiff?.changes))throw Error('Complete baseline, group and user-profile differences are required before restoration. Refresh and compare again.');if(comparison.truncated||comparison.groupDiff.truncated||comparison.profileDiff.truncated)throw Error('Policy difference exceeds the supported review size. Review the policy through the API before restoration.');
|
||||
|
||||
historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...comparison.groupDiff.changes.map(change=>({...change,path:`/policyGroups${change.path}`})),...comparison.profileDiff.changes.map(change=>({...change,path:`/userProfiles${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths, ${comparison.groupDiff.changes.length} group paths and ${comparison.profileDiff.changes.length} user-profile/category paths changed. Review verified-subject and device assignments, website access, category definitions and developer-tool grants below. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||
historyReview={sourceVersion:version,expectedVersion:current.currentVersion};renderHistoryDiff([...comparison.changes,...comparison.groupDiff.changes.map(change=>({...change,path:`/policyGroups${change.path}`})),...comparison.profileDiff.changes.map(change=>({...change,path:`/userProfiles${change.path}`}))]);$('history-review-title').textContent=`Review v${version} against current v${current.currentVersion}`;$('history-review-summary').textContent=`${comparison.changes.length} baseline paths, ${comparison.groupDiff.changes.length} group paths and ${comparison.profileDiff.changes.length} user-profile/category paths changed. Review verified-subject and device assignments, website access, category definitions and developer-tool grants below. Restoration publishes v${current.currentVersion+1} with a fresh seven-day expiry. Your unpublished draft will be replaced after successful restoration.`;const grantChanges=[...comparison.changes,...comparison.groupDiff.changes,...comparison.profileDiff.changes].some(change=>/(?:^|\/)agents\/grants(?:\/|$)/.test(change.path)||[change.before,change.after].some(value=>value&&typeof value==='object'&&JSON.stringify(value).includes('"grants"')));if(grantChanges)$('history-review-summary').textContent+=' Agent credential warning: each enrolled browser preserves its highest observed credential generation. Restoring an older generation does not restore invalidated credentials and leaves that grant disabled on browsers that observed a higher generation. To reactivate safely, publish a generation greater than every previously issued generation and pair the agent again locally.'+(independentApprovalRequired?' Independent approval still applies; this checkbox only prepares restoration.':'');$('history-review').hidden=false;$('history-approve').disabled=!canWritePolicy()||version===current.currentVersion;$('history-restore').disabled=true;$('history-review').scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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="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 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
|
||||
<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 clipboard, agent-click and observed page-gesture 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, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. 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 diaLine truncated
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -96,3 +96,15 @@ 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.
|
||||
|
||||
Foreground gesture development used actual traces to find a second attribution
|
||||
gap: a queue flusher started by one event delivered the next event under the first
|
||||
async context. `withCorrelation` now validates each durable record's own IDs and
|
||||
`audit.deliver` creates a recording delivery child under that source parent.
|
||||
Malformed IDs clear unrelated context, and current signed sampling/disable still
|
||||
applies. Real OTLP regression tests and three actual human-event traces prove
|
||||
distinct parents and complete evidence phase correlation. Trace
|
||||
`b7783f42197d0cff83957b0ee96e8296` has 17 spans, capture 19.50 ms and send 13.20 ms;
|
||||
`62e09413b9a42f4a58bd7f99485176ac` and
|
||||
`545ddac025113e50effed4e9a2ac87ee` each also have 17 spans. Collector rejected writes
|
||||
were zero. These are local reproduction timings, not throughput guarantees.
|
||||
@@ -24,4 +24,12 @@ reliable ownership support. Actual exported agent request traces carry named chi
|
||||
phases and link to attributable durable events. The package verifier runs the
|
||||
shipped individual client. A separate backend integration test exercises distinct
|
||||
reviewer publication and history restore; grant-specific native publication UI
|
||||
coverage remains an open acceptance gate.
|
||||
coverage is now verified by `scripts/agent-grants-history-admin-smoke.mjs`.
|
||||
It exercises the actual author and independent-reviewer UI, denied self-approval,
|
||||
read-only auditor, rotation and draft-only history restoration while preserving
|
||||
an unrelated unpublished draft. Exported requests have verified parent trace
|
||||
correlation; screenshots were visually inspected. The history summary warns
|
||||
that restoring a lower generation cannot revive invalidated credentials: publish
|
||||
a generation greater than every previously issued value and pair again.
|
||||
|
||||

|
||||
@@ -2,20 +2,25 @@
|
||||
|
||||
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.
|
||||
Interaction capture covers confirmed guarded copy/cut/paste, completed agent
|
||||
clicks and trusted clicks/gesture-associated form events on the active main-frame
|
||||
page. Human events are marked `observed`, including prevented events; this never
|
||||
claims transmission or completion of a page's asynchronous effect.
|
||||
|
||||
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.
|
||||
fields do not produce this capture. Sensitive forms are excluded as a whole,
|
||||
including password/file/hidden controls and bounded credential/card markers.
|
||||
Form observation also requires a recent trusted click or Enter gesture and active
|
||||
user activation; `requestSubmit()` alone does not establish a human gesture.
|
||||
|
||||
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
|
||||
and the correct observed/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
|
||||
@@ -27,10 +32,22 @@ 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
|
||||
with correlated server decrypt/store phases. Queue delivery resumes each record's
|
||||
own trace parent rather than inheriting a different event's flusher context.
|
||||
Observer install/drain/dispose phases have separate bounded debug spans.
|
||||
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
|
||||
The private isolated-world observer has no page-visible bridge. It expires after
|
||||
1.5 seconds without a privileged drain, caps its queue at 16, allows a burst of
|
||||
20 and replenishes at 10 events per second. Main validates every drained record
|
||||
and rejects changed documents, activation, policy, ownership and overlays. A
|
||||
visual reinspection of the same document does not invalidate captured metadata.
|
||||
Rate/queue losses emit a durable bounded-capture warning and a diagnostic count;
|
||||
capture is not a complete forensic replay. Subframes, shadow internals, all
|
||||
keyboard actions and asynchronous effect reconstruction remain outside coverage.
|
||||
|
||||
Remaining coverage includes 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -501,3 +501,66 @@ 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.
|
||||
|
||||
## Foreground gesture evidence and reviewed agent history
|
||||
|
||||
The browser now observes trusted foreground main-document clicks and
|
||||
gesture-associated submit events in a private isolated world. Signed evidence
|
||||
policy controls collection; sensitive forms, synthetic events and raw values
|
||||
are excluded. New server event/source validation accepts only matching observed
|
||||
click/submit facts. ECS explicitly reports an unknown outcome rather than claiming
|
||||
successful transmission. Bounded collection and coverage limitations are described
|
||||
in `docs/interaction-evidence.md`; this is not full replay or content capture.
|
||||
|
||||
Actual native proof verifies seven protected records, seven audited analyst
|
||||
reveals, synthetic `click`/`dispatchEvent`/`requestSubmit` exclusion, password-form
|
||||
exclusion, independent reviewed disable and continued scanned download evidence.
|
||||
First failures identified the missing server event enum; subsequent trace checks
|
||||
identified shared flusher context attributing two queued records to one source.
|
||||
Both are fixed and negative/real-OTLP tests added. Each queued record now resumes
|
||||
its own validated trace parent. All three actual human events have 17 correlated
|
||||
spans each (`artifacts/human-interaction-trace-proof.json`). A visual DOM
|
||||
reinspection does not imply another document; true document, activation, policy,
|
||||
URL and native ownership changes still invalidate observation.
|
||||
|
||||
The native admin workflow now verifies grant creation, separate review,
|
||||
self-approval refusal, auditor read-only controls, generation rotation and history
|
||||
restoration without discarding an unrelated draft. The history view warns that
|
||||
old credential generations cannot be resurrected and require a higher generation
|
||||
plus re-pairing. Screenshots were visually inspected; real backend OTLP parents
|
||||
verified (`artifacts/agent-grants-history-admin-proof.json`).
|
||||
|
||||
The separately evidenced shutdown cleanup error is fixed using an immutable,
|
||||
non-enumerable native WebContents reference; destroyed views are removable and
|
||||
closing does not reactivate remaining tabs. This does not establish the cause of
|
||||
the earlier startup timeout. The current suite passes 546 tests. Production key
|
||||
custody, signed installer/update distribution, worker/session ownership, full
|
||||
secured agent capabilities, content capture, external customer integrations and
|
||||
independent assessment remain open. The full objective is not complete.
|
||||
|
||||
Final Windows build and packaged-source/runtime verification pass, including
|
||||
the new observer, contained scanner, immutable upload leases, offline semantic
|
||||
inference and shipped protected agent clients. Syntax and actual native tab/DLP
|
||||
tests pass (`artifacts/human-windows-build.log`, `human-package-proof.log`,
|
||||
`human-final-syntax.log`, `human-native-tabs.log`). Authenticode is still
|
||||
`NotSigned`; an unpacked build is not a signed production installer.
|
||||
|
||||
### Next priorities against the original requirements
|
||||
|
||||
1. Complete controlled agent capabilities: history/session restoration, window
|
||||
management, safe visual capture, vault/file operations and administrative
|
||||
actions need dedicated scopes, principal ownership, central disable, DLP and
|
||||
durable attribution. Do not supply a generic JavaScript/DevTools bypass.
|
||||
2. Support legitimate background workers in individual mode with provable native
|
||||
ownership/session isolation; ownerless requests currently remain blocked.
|
||||
3. Prove legitimate credential submission without exempting arbitrary password
|
||||
controls or bypassing inspection of other form fields and changed targets.
|
||||
4. Expand protected evidence to remaining interactions and controlled content
|
||||
workflows with collection-gap visibility and purpose-bound analyst access.
|
||||
Existing observed gestures do not establish full reconstruction.
|
||||
5. Verify streaming/ranged/native upload paths and main/network-service crash
|
||||
semantics, then production enrollment/device key custody/rotation, signed
|
||||
installer/update delivery and real customer SSO/ICAP/SIEM/HA integrations.
|
||||
These require independent deployment evidence, not only mocked local tests.
|
||||
|
||||
Mac/Linux/mobile remain explicitly deferred by the user's Windows-first scope.
|
||||
@@ -2,7 +2,11 @@
|
||||
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(),
|
||||
action:z.enum(['click','copy','cut','paste','submit']),sessionId:z.string().uuid(),outcome:z.enum(['completed','observed']),captureSource:z.enum(['guarded-clipboard','agent-adapter','human-observer']),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();
|
||||
}).strict().superRefine((value,context)=>{
|
||||
const human=value.captureSource==='human-observer';
|
||||
if(human){try{if(value.pageUrl!==new URL(value.pageUrl).origin+'/')context.addIssue({code:'custom',message:'Human metadata must contain origin only'});}catch{context.addIssue({code:'custom',message:'Invalid human metadata origin'});}}
|
||||
if(human?(value.outcome!=='observed'||!['click','submit'].includes(value.action)||value.bytes!==undefined):(value.outcome!=='completed'||value.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(value.action)||value.captureSource==='agent-adapter'&&value.action!=='click'))context.addIssue({code:'custom',message:'Interaction source, action and outcome must agree'});
|
||||
});
|
||||
module.exports={InteractionEvidenceSchema};
|
||||
@@ -1,10 +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();
|
||||
const InteractionFactsSchema=z.object({action:z.enum(['copy','cut','paste','click','submit']),outcome:z.enum(['authorized','completed','denied','observed']),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==='browser.interaction')return facts.outcome==='observed'&&['click','submit'].includes(facts.action)?facts:null;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function toECS(event,installationId='safetynet') {
|
||||
'@timestamp':event.time,ecs:{version:'8.17.0'},
|
||||
event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security',
|
||||
action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'],
|
||||
type:[alert?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
||||
type:[alert||interaction?.outcome==='observed'?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:interaction?.outcome==='observed'?'unknown':failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
||||
observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'},
|
||||
...(event.device_id?{host:{id:event.device_id}}:{}),
|
||||
...(m.actor?{user:{id:m.actor}}:{}),...(m.host?{destination:{domain:m.host}}:{}),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
const {app,BrowserWindow,session}=require('electron'),assert=require('node:assert/strict'),fs=require('node:fs');app.disableHardwareAcceleration();app.enableSandbox();app.setPath('userData',process.env.SAFETYNET_ADMIN_SMOKE_PROFILE);let window;const deadline=setTimeout(()=>app.exit(1),55000);const evaluate=s=>window.webContents.executeJavaScript(s,true);
|
||||
async function waitFor(s){for(let n=0;n<200;n++){if(await evaluate(s))return;await new Promise(r=>setTimeout(r,60));}throw Error('UI state timeout: '+s+' | '+await evaluate(`document.getElementById('status').textContent`));}
|
||||
async function identity(value){await session.defaultSession.cookies.set({url:process.env.SAFETYNET_ADMIN_SMOKE_URL,name:'sn_session',value,httpOnly:true,sameSite:'strict'});await window.loadURL(process.env.SAFETYNET_ADMIN_SMOKE_URL);await waitFor(`!document.getElementById('workspace').hidden&&document.getElementById('reviews-status').textContent.includes('loaded')`);}
|
||||
async function selectReview(){await evaluate(`document.querySelector('#reviews-rows button').click()`);await waitFor(`!document.getElementById('independent-review-detail').hidden`);}
|
||||
async function approve(version){assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await evaluate(`document.getElementById('independent-review-confirm').checked=true;document.getElementById('independent-review-confirm').dispatchEvent(new Event('change'));document.getElementById('independent-review-approve').click()`);await waitFor(`document.getElementById('policy-version').textContent==='v${version}'`);}
|
||||
async function capture(file,section){await evaluate(`document.getElementById('${section}').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);await new Promise(r=>setTimeout(r,250));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(file,(await window.webContents.capturePage()).toPNG());}
|
||||
app.whenReady().then(async()=>{
|
||||
window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}});
|
||||
const proofTraceIds=[];session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');proofTraceIds.push({traceId,parentSpanId:spanId});callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});});
|
||||
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`);
|
||||
await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);
|
||||
assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403);
|
||||
await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2);
|
||||
await identity('grant-auditor');assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true);
|
||||
await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3);
|
||||
await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4);
|
||||
fs.writeFileSync('artifacts/agent-grants-admin-request-trace-ids.json',JSON.stringify(proofTraceIds));console.log('GRANT_ADMIN_OK');clearTimeout(deadline);window.destroy();app.exit(0);
|
||||
}).catch(error=>{console.error('GRANT_ADMIN_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,rmSync,writeFileSync,readFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve,sep,basename} from 'node:path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {digest,openStore} from '../apps/server/store.mjs';
|
||||
import {createApp} from '../apps/server/app.mjs';
|
||||
import {verifyPolicy} from '../packages/core/policy.mjs';
|
||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT='http://127.0.0.1:4318';
|
||||
const health=await fetch('http://127.0.0.1:4318/health').then(r=>r.json());assert.equal(health.rejectedWrites,0);
|
||||
const sdk=startTelemetry(),require=createRequire(import.meta.url),directory=mkdtempSync(join(tmpdir(),'safetynet-agent-grant-admin-'));let store,server,child;
|
||||
try {
|
||||
store=openStore(join(directory,'management'));for(const [credential,role,sub] of [['grant-author','admin','grant-author'],['grant-reviewer','admin','grant-reviewer'],['grant-auditor','auditor','grant-auditor']])store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+3600000,JSON.stringify({sub,role}));
|
||||
const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`;
|
||||
const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE;
|
||||
child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0);
|
||||
const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');
|
||||
const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[];for(const item of traces){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
|
||||
} finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -23,12 +23,18 @@ app.whenReady().then(async()=>{try{
|
||||
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);
|
||||
stage='human observer installation';await until(()=>page.executeJavaScriptInIsolatedWorld(998,[{code:"!!globalThis.__safetynetHumanInteraction"}],false));await page.executeJavaScript("window.__fixtureTrustedClick=false;window.addEventListener('click',e=>{window.__fixtureTrustedClick=e.isTrusted},true)");
|
||||
stage='synthetic human exclusion';await page.executeJavaScript("document.getElementById('public-button').click();document.getElementById('public-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));document.getElementById('public-form').requestSubmit()");await wait(700);assert.equal((await records()).length,4);
|
||||
async function physicalClick(id){const point=await page.executeJavaScript(`(()=>{const r=document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();return {x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)}})()`);page.focus();page.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...point});page.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,...point});await wait(900);}
|
||||
stage='physical click';await physicalClick('public-button');assert.equal(await page.executeJavaScript('window.__fixtureTrustedClick'),true);assert.equal(await page.executeJavaScript("document.getElementById('public-button').dataset.clicked"),'3');await until(async()=>(await records()).length===5);
|
||||
stage='physical submit';await physicalClick('submit-button');assert.equal(await page.executeJavaScript("document.getElementById('public-form').dataset.submitted"),'3');await until(async()=>(await records()).length===7);
|
||||
stage='private form exclusion';await physicalClick('private-submit');await wait(500);assert.equal((await records()).length,7);
|
||||
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'));
|
||||
assert.deepEqual(payloads.map(p=>p.action).sort(),['click','click','click','copy','cut','paste','submit']);assert.ok(payloads.every(p=>p.sessionId===sessionId&&p.sensitiveField===false));assert.equal(payloads.filter(p=>p.captureSource==='human-observer'&&p.outcome==='observed').length,3);assert.ok(payloads.every(p=>p.pageUrl===new URL(p.pageUrl).origin+'/'));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);
|
||||
for(const marker of ['Synthetic public selection','Synthetic pasted text','Synthetic control label','Synthetic password attempt','never-export-this','Synthetic private form field','#public-button','#private-input']){assert.equal(serialized.includes(marker),false);assert.equal(JSON.stringify(eventsBefore).includes(marker),false);}
|
||||
assert.equal(eventsBefore.filter(e=>e.kind==='evidence.accessed').length,7);
|
||||
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})()"));
|
||||
@@ -37,9 +43,9 @@ app.whenReady().then(async()=>{try{
|
||||
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='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);await physicalClick('public-button');assert.equal((await records()).length,7);
|
||||
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));
|
||||
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));sources.sort((a,b)=>Number(b.kind==='agent.action')-Number(a.kind==='agent.action'));assert.equal(sources.filter(e=>e.kind==='browser.interaction').length,3);
|
||||
fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-proof.json'),JSON.stringify({actualElectron:true,physicalTrustedClickAndSubmit:true,syntheticHumanEventsExcluded:true,passwordFormsExcluded:true,humanObservationNotTransmission: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),humanTraceIds:sources.filter(e=>e.kind==='browser.interaction').map(e=>e.metadata.traceId),humanEventIds:sources.filter(e=>e.kind==='browser.interaction').map(e=>e.id)},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);}});
|
||||
}catch(error){if(originalClipboard!==undefined)await clipboard.writeText(originalClipboard);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);let checkpoint={};try{if(page&&!page.isDestroyed())checkpoint.observer=await page.executeJavaScriptInIsolatedWorld(998,[{code:"(()=>{const s=globalThis.__safetynetHumanInteraction;return {installed:!!s,pending:s?.queue?.length||0,dropped:s?.dropped||0}})()"}],false);const queuePath=path.join(process.env.SAFETYNET_SMOKE_USERDATA,'audit-queue.bin');if(fs.existsSync(queuePath)){const queue=JSON.parse(require('electron').safeStorage.decryptString(fs.readFileSync(queuePath)));checkpoint.pendingHumanSources=queue.filter(e=>e.kind==='browser.interaction').slice(0,16).map(({eventId,traceId,spanId,policyVersion})=>({eventId,traceId,spanId,policyVersion}));}}catch{}fs.writeFileSync(path.resolve('artifacts/interaction-evidence-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message,checkpoint},null,2));console.error(`Interaction evidence fixture failed at ${stage}: ${error.name}`);app.exit(1);}});
|
||||
@@ -14,7 +14,7 @@ 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 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><form id="public-form" onsubmit="event.preventDefault();this.dataset.submitted=String(Number(this.dataset.submitted||0)+1)"><input type="text" value="Synthetic private form field"><button id="submit-button" type="submit">Send form</button></form><form id="private-form" onsubmit="event.preventDefault()"><input type="password" value="never-export-this"><button id="private-submit">Private submit</button></form><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)}});
|
||||
@@ -48,6 +48,15 @@ if(exitCode===0&&process.env.SAFETYNET_TEST_OTLP_ENDPOINT){
|
||||
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');
|
||||
const humanTraceProof=[];
|
||||
for(let i=0;i<proof.humanTraceIds.length;i++){
|
||||
const item=await fetch(endpoint+'/api/trace/'+proof.humanTraceIds[i]).then(r=>r.json()),humanSpans=item.spans||[];
|
||||
const capture=humanSpans.find(span=>span.name==='browser.evidence'&&span.operation==='evidence.capture');
|
||||
if(!capture||!humanSpans.some(span=>span.name==='evidence.store')||!['evidence.challenge','evidence.encrypt','evidence.send','evidence.ack'].every(operation=>humanSpans.some(span=>span.name==='browser.evidence'&&span.operation===operation))){writeFileSync(resolve('artifacts/human-interaction-trace-error.json'),JSON.stringify({traceId:item.traceId,eventId:proof.humanEventIds[i],spans:humanSpans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))},null,2));throw Error('Physical human source evidence trace phases missing');}
|
||||
if(!humanSpans.filter(span=>span.parentSpanId).every(span=>humanSpans.some(parent=>parent.spanId===span.parentSpanId)))throw Error('Physical human evidence trace parent missing');
|
||||
humanTraceProof.push({traceId:item.traceId,eventId:proof.humanEventIds[i],spanCount:humanSpans.length,captureMs:capture.durationMs,parentCorrelation:true,phases:humanSpans.filter(span=>span.name==='browser.evidence').map(({operation,durationMs})=>({operation,durationMs}))});
|
||||
}
|
||||
writeFileSync(resolve('artifacts/human-interaction-trace-proof.json'),JSON.stringify({humanTraceProof,collectorRejectedWrites:health.rejectedWrites,collectorStoredBytes:health.storedBytes},null,2));
|
||||
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);
|
||||
@@ -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}`);
|
||||
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');
|
||||
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/human-interaction.cjs','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/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}`);
|
||||
|
||||
@@ -22,3 +22,8 @@ for(const part of ['baseline','group','profile'])test(`truncated ${part} diff ne
|
||||
});
|
||||
test('missing user-profile diff fails closed rather than treating it as unchanged',async()=>{const comparison=empty();delete comparison.profileDiff;const f=fixture(comparison);await assert.rejects(f.review(),/Complete baseline, group and user-profile/);assert.equal(f.get('history-review').hidden,true);assert.equal(vm.runInContext('historyReview',f.context),null);});
|
||||
test('profile values are rendered as literal text and read-only identities cannot approve',async()=>{const comparison=empty();comparison.profileDiff.changes=[{path:'/profiles/0/name',kind:'changed',before:'<img src=x onerror=alert(1)>',after:'Current'}];const f=fixture(comparison,['policy.read']);await f.review();assert.match(f.get('history-diff-list').children[0].children[1].textContent,/<img src=x onerror=alert\(1\)>/);assert.equal(f.get('history-approve').disabled,true);assert.equal(f.get('history-restore').disabled,true);});
|
||||
|
||||
for(const [part,path] of [['baseline','/agents/grants'],['group','/0/policy/agents/grants'],['profile','/profiles/0/policy/agents/grants']])test(`grant ${part} restoration explains persistent generation floors before confirmation`,async()=>{
|
||||
const comparison=empty(),change={path,kind:'changed',before:[{id:'research-agent',generation:1}],after:[{id:'research-agent',generation:2}]};(part==='baseline'?comparison.changes:comparison[`${part}Diff`].changes).push(change);const f=fixture(comparison);await f.review();assert.match(f.get('history-review-summary').textContent,/highest observed credential generation/);assert.match(f.get('history-review-summary').textContent,/does not restore invalidated credentials/);assert.match(f.get('history-review-summary').textContent,/greater than every previously issued generation/);assert.equal(f.get('history-restore').disabled,true);
|
||||
});
|
||||
test('unrelated restoration does not imply a credential-generation change',async()=>{const comparison=empty();comparison.changes=[{path:'/brand/name',kind:'changed',before:'Previous',after:'Current'}];const f=fixture(comparison);await f.review();assert.doesNotMatch(f.get('history-review-summary').textContent,/Agent credential warning/);});
|
||||
@@ -52,3 +52,30 @@ test('export failure totals survive disable and reconfiguration without resendin
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
test('one asynchronous audit flusher resumes each persisted source parent in real OTLP', {timeout:15000},async t=>{
|
||||
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const rows=[];let calls=0;
|
||||
await diagnostics.span('browser.clipboard',{operation:'copy'},async()=>rows.push({...diagnostics.correlation(),secret:'PERSISTED_SECRET'}));
|
||||
await diagnostics.span('browser.agent',{operation:'click'},async()=>rows.push({...diagnostics.correlation()}));
|
||||
// The flusher retains an unrelated span; neither delivery may adopt it.
|
||||
await diagnostics.span('browser.management',{operation:'sync'},async()=>{
|
||||
for(const row of rows)await diagnostics.withCorrelation(row,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{
|
||||
calls++;await delay(2);await diagnostics.span('browser.request',{operation:'request'},async()=>{});
|
||||
}));
|
||||
});await diagnostics.flush();const output=spans(requests),deliveries=output.filter(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue==='audit.deliver'));
|
||||
assert.equal(calls,2);assert.equal(deliveries.length,2);for(const row of rows){const delivery=deliveries.find(s=>s.traceId===row.traceId);assert.ok(delivery);assert.equal(delivery.parentSpanId,row.spanId);assert.ok(output.some(s=>s.name==='browser.request'&&s.traceId===row.traceId&&s.parentSpanId===delivery.spanId));}
|
||||
assert.equal(JSON.stringify(requests).includes('PERSISTED_SECRET'),false);assert.equal(output.length,7);
|
||||
});
|
||||
|
||||
test('invalid persisted IDs clear unrelated parents without getters, retries or disabled exports',{timeout:15000},async t=>{
|
||||
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));const good={traceId:'123456789abcdef0123456789abcdef0',spanId:'123456789abcdef0'};let calls=0,getters=0;
|
||||
const accessor={spanId:good.spanId};Object.defineProperty(accessor,'traceId',{get(){getters++;throw Error('Never invoke');}});
|
||||
const invalid=[null,[],accessor,{...good,traceId:'0'.repeat(32)},{...good,spanId:'0'.repeat(16)},{...good,traceId:good.traceId.toUpperCase()},{...good,traceId:good.traceId+'x'},Object.create(good),new Proxy({},{ownKeys(){throw Error('Refuse metadata');}})];
|
||||
let unrelated;await diagnostics.span('browser.agent',{operation:'click'},async()=>{
|
||||
unrelated=diagnostics.correlation();for(const ids of invalid)await diagnostics.withCorrelation(ids,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));
|
||||
});await diagnostics.flush();assert.equal(getters,0);assert.equal(calls,invalid.length);for(const item of spans(requests).filter(s=>s.name==='browser.management')){assert.notEqual(item.traceId,unrelated.traceId);assert.ok(!item.parentSpanId);}
|
||||
const failure=Error('Operation failed');assert.throws(()=>diagnostics.withCorrelation(good,()=>{calls++;throw failure;}),error=>error===failure);
|
||||
const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);
|
||||
diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3);
|
||||
});
|
||||
@@ -7,6 +7,18 @@ function protectFixtureKey(file){
|
||||
async function fixture(t,{configured=true}={}){const dir=mkdtempSync(join(tmpdir(),'safetynet-evidence-')),keyFile=join(dir,'evidence.key');writeFileSync(keyFile,randomBytes(32),{mode:0o600});protectFixtureKey(keyFile);const store=openStore(dir);const evidencePolicy=store.get('policy');evidencePolicy.evidence={enabled:true,captureDownloads:true,captureInteractions:true};store.put('policy',evidencePolicy);const deviceToken='synthetic-device-evidence-credential-1234567890',id=randomUUID();store.db.prepare('INSERT INTO devices(id,token_hash,revoked) VALUES(?,?,0)').run(id,digest(deviceToken));const config={publicUrl:'http://127.0.0.1:4310',...(configured?{evidenceKeyFile:keyFile}:{})},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});});const api=async(path,method='GET',body,role='device')=>{const credential=`synthetic-${role}-evidence-credential-1234567890`;if(role!=='device')store.db.prepare('INSERT OR REPLACE INTO sessions VALUES(?,?,?)').run(digest(credential),Date.now()+60000,JSON.stringify({sub:role,role}));const r=await fetch(`http://127.0.0.1:${server.address().port}${path}`,{method,headers:{...(role==='device'?{authorization:`Bearer ${deviceToken}`}:{cookie:`sn_session=${credential}`}),origin:config.publicUrl,'content-type':'application/json'},body:body?JSON.stringify(body):undefined});return {status:r.status,body:await r.json()};};const eventId=randomUUID();store.event(id,'clipboard.copy',{eventId,policyVersion:1,interaction:{action:'copy',outcome:'completed',sessionId:'00000000-0000-4000-8000-000000000001'}});return {store,api,id,eventId,config};}
|
||||
const evidence=eventId=>({eventId,type:'interaction',payload:{pageUrl:'https://intranet.example/document',action:'copy',sessionId:'00000000-0000-4000-8000-000000000001',outcome:'completed',captureSource:'guarded-clipboard',sensitiveField:false}});
|
||||
|
||||
test('human observations bind exact event action and session without claiming transfer completion',async t=>{
|
||||
const {api,store,id}=await fixture(t),sessionId=randomUUID();
|
||||
for(const action of ['click','submit']){
|
||||
const eventId=randomUUID(),payload={pageUrl:'https://intranet.example/',action,sessionId,outcome:'observed',captureSource:'human-observer',sensitiveField:false,target:{tag:'BUTTON'}};
|
||||
assert.equal((await api('/api/device/events','POST',{eventId,kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome:'observed'}})).status,200);
|
||||
for(const outcome of ['completed','authorized','denied'])assert.equal((await api('/api/device/events','POST',{eventId:randomUUID(),kind:'browser.interaction',policyVersion:1,interaction:{action,sessionId,outcome}})).status,400);
|
||||
for(const extra of [{action:action==='click'?'submit':'click'},{outcome:'completed'},{sessionId:randomUUID()},{captureSource:'agent-adapter'},{bytes:1},{content:'PRIVATE_BODY'}])assert.equal((await api('/api/device/evidence','POST',{eventId,type:'interaction',payload:{...payload,...extra}})).status,400);
|
||||
const accepted=await api('/api/device/evidence','POST',{eventId,type:'interaction',payload});assert.equal(accepted.status,201);
|
||||
assert.deepEqual((await api(`/api/admin/evidence/${accepted.body.id}?reason=investigation`,'GET',undefined,'analyst')).body.payload,payload);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||
function fixture(){
|
||||
const contents={isDestroyed:()=>false,getURL:()=> 'https://example.test/page'},tab={id:'tab-1',contents,startPage:false,security:{interactionDocumentGeneration:2}},live={navigationId:8,activation:4,paused:false},policy={version:3,expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}};
|
||||
const c=vm.createContext({ready:true,policy,activeTab:()=>tab,tabs:{context:()=>({...live})},tabContext:()=>({...live}),currentTabContext:()=>true,overviewPanel:false,toolsOpen:false});
|
||||
vm.runInContext(source.slice(source.indexOf('function interactionObserverContext(tab)'),source.indexOf('function installSiteHooks(tab)')),c);
|
||||
return {c,tab,live,contents,context:c.interactionObserverContext(tab)};
|
||||
}
|
||||
test('human observation survives visual DOM revisions but rejects real document or activation changes',()=>{
|
||||
const f=fixture();assert.equal(f.c.currentInteractionObserverContext(f.context),true);f.live.navigationId++;assert.equal(f.c.currentInteractionObserverContext(f.context),true,'Visual reinspection does not imply another document');f.tab.security.interactionDocumentGeneration++;assert.equal(f.c.currentInteractionObserverContext(f.context),false);f.tab.security.interactionDocumentGeneration--;f.live.activation++;assert.equal(f.c.currentInteractionObserverContext(f.context),false);
|
||||
});
|
||||
test('human observation rejects revocation, disabled capture, expiry, overlays and changed native contents',()=>{
|
||||
for(const mutate of [f=>f.c.ready=false,f=>f.c.policy.evidence.captureInteractions=false,f=>f.c.policy.version++,f=>f.c.policy.expiresAt=new Date(0).toISOString(),f=>f.c.toolsOpen=true,f=>f.c.overviewPanel=true,f=>f.tab.startPage=true,f=>f.tab.contents={...f.contents},f=>f.contents.getURL=()=> 'https://example.test/other',f=>f.live.paused=true]){const f=fixture();mutate(f);assert.equal(f.c.currentInteractionObserverContext(f.context),false);}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
const {humanInteractionScript}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
|
||||
const token='a'.repeat(32),url='https://intranet.example/path?private=1';
|
||||
function fixture(){
|
||||
let time=1000;const listeners=new Map();const win={addEventListener:(type,fn)=>listeners.set(type,fn),removeEventListener:(type,fn)=>{if(listeners.get(type)===fn)listeners.delete(type);}};win.top=win;
|
||||
const context=vm.createContext({window:win,location:{href:url},innerWidth:1000,innerHeight:800,navigator:{userActivation:{isActive:true}},Date:{now:()=>time}});
|
||||
const run=mode=>vm.runInContext(humanInteractionScript(mode,{url,token}),context);
|
||||
const target=(tag='BUTTON',attrs={})=>({nodeType:1,tagName:tag,isConnected:true,parentElement:null,type:'button',hasAttribute:key=>Object.hasOwn(attrs,key),getAttribute:key=>attrs[key]||null,closest:()=>null});
|
||||
const emit=(type,element=target(),extra={})=>listeners.get(type)?.({type,target:element,isTrusted:true,clientX:12.2,clientY:31.8,...extra});
|
||||
return {run,target,emit,listeners,context,advance:ms=>{time+=ms;}};
|
||||
}
|
||||
test('trusted observation exports structural metadata only, rejects synthetic events',()=>{
|
||||
const f=fixture();assert.equal(f.run('install').installed,true);
|
||||
f.emit('click',f.target('BUTTON',{'aria-label':'Confidential deal','data-value':'sensitive'}),{isTrusted:false});
|
||||
assert.equal(f.run('drain').events.length,0);
|
||||
f.emit('click',f.target('BUTTON',{'aria-label':'ordinary button','id':'id-private','data-value':'sensitive'}));
|
||||
const result=JSON.parse(JSON.stringify(f.run('drain')));
|
||||
assert.deepEqual(result.events,[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON',x:12,y:32}}]);
|
||||
assert.equal(JSON.stringify(result).includes('id-private'),false);
|
||||
});
|
||||
test('password, credential ancestors and sensitive forms never contribute metadata',()=>{
|
||||
const f=fixture();f.run('install');const password=f.target('INPUT');password.type='password';
|
||||
f.emit('click',password);const child=f.target();child.parentElement=f.target('DIV',{'data-sensitive':''});f.emit('click',child);
|
||||
const form=f.target('FORM');form.elements=[password];f.emit('submit',form);const button=f.target();button.form=form;f.emit('click',button);
|
||||
assert.equal(f.run('drain').events.length,0);
|
||||
});
|
||||
test('submit is observed, not evidence of transmission, and carries no fields',()=>{
|
||||
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[f.target('INPUT')];form.elements[0].value='company confidential';
|
||||
f.emit('keydown',form.elements[0],{key:'Enter'});
|
||||
f.emit('submit',form,{defaultPrevented:true});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(f.run('drain').events)),[{action:'submit',outcome:'observed',sensitiveField:false,target:{tag:'OTHER'}}]);
|
||||
});
|
||||
test('queue and rate are bounded, lease expiry and navigation prevent new capture',()=>{
|
||||
const f=fixture();f.run('install');for(let i=0;i<1000;i++)f.emit('click');
|
||||
let result=f.run('drain');assert.equal(result.events.length,16);assert.equal(result.dropped,984);
|
||||
for(let i=0;i<100;i++)f.emit('click');assert.equal(f.run('drain').events.length,4);
|
||||
f.advance(1600);f.emit('click');assert.equal(f.run('drain').events.length,0);
|
||||
f.context.location.href='https://other.example/';f.emit('click');assert.equal(f.run('drain'),null);
|
||||
});
|
||||
test('replacement and dispose remove listeners and pending metadata',()=>{
|
||||
const f=fixture();f.run('install');f.emit('click');f.run('install');assert.equal(f.run('drain').events.length,0);
|
||||
f.run('dispose');assert.equal(f.listeners.size,0);assert.equal(f.run('drain'),null);
|
||||
assert.throws(()=>humanInteractionScript('install',{url,token:'untrusted'}));
|
||||
});
|
||||
const {createHumanInteractionObserver}=createRequire(import.meta.url)('../apps/browser/human-interaction.cjs');
|
||||
function controllerFixture(){
|
||||
const dom=fixture();const contents={isDestroyed:()=>false};let context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:contents};const seen=[];
|
||||
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:s=>context.ready&&context.enabled&&s.token===context.token&&s.policyVersion===context.policyVersion&&s.foreground===context.foreground,executeScript:async(_contents,code)=>vm.runInContext(code,dom.context),onObserved:async event=>seen.push(event)});
|
||||
return {dom,observer,seen,set:update=>{context={...context,...update};}};
|
||||
}
|
||||
test('controller binds current navigation and policy and drops queued stale metadata',async()=>{
|
||||
const f=controllerFixture();try{
|
||||
await f.observer.poll();f.dom.emit('click');await f.observer.poll();assert.equal(f.seen.length,1);
|
||||
f.dom.emit('click');f.set({token:'nav-2'});await f.observer.poll();await f.observer.poll();assert.equal(f.seen.length,1);
|
||||
f.dom.emit('click');f.set({enabled:false});await f.observer.poll();assert.equal(f.dom.listeners.size,0);assert.equal(f.seen.length,1);
|
||||
}finally{f.observer.dispose();}
|
||||
});
|
||||
test('refresh and dispose invalidate in-flight batches without replay',async()=>{
|
||||
let resolve;const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};const seen=[];let calls=0;
|
||||
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};return new Promise(r=>{resolve=r;});},onObserved:async value=>seen.push(value)});
|
||||
try{await observer.poll();const pending=observer.poll();observer.refresh();resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
|
||||
});
|
||||
test('submit without native user activation is excluded',()=>{
|
||||
const f=fixture();f.run('install');f.context.navigator.userActivation.isActive=false;const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
|
||||
});
|
||||
test('late DOM drain remains serialized and expires without invoking audit',async()=>{
|
||||
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let resolve,calls=0;const seen=[];
|
||||
const observer=createHumanInteractionObserver({deadline:100,getContext:()=>context,isCurrent:()=>true,executeScript:async()=>{calls++;if(calls===1)return {installed:true};if(calls===2)return new Promise(r=>{resolve=r;});return null;},onObserved:async event=>seen.push(event)});
|
||||
try{await observer.poll();const pending=observer.poll();await new Promise(r=>setTimeout(r,120));await observer.poll();assert.equal(calls,2);resolve({events:[{action:'click',outcome:'observed',sensitiveField:false,target:{tag:'BUTTON'}}],dropped:0});await pending;assert.equal(seen.length,0);}finally{observer.dispose();}
|
||||
});
|
||||
test('destroyed renderer disposal errors never escape lifecycle callback',async()=>{
|
||||
const context={ready:true,enabled:true,foreground:true,sessionId:'c391515c-cd19-4009-b042-e04d723d9f11',url,token:'nav-1',policyVersion:1,webContents:{isDestroyed:()=>false}};let installed=false;
|
||||
const observer=createHumanInteractionObserver({getContext:()=>context,isCurrent:()=>true,executeScript:()=>{if(!installed){installed=true;return {installed:true};}throw Error('Destroyed renderer');},onObserved:()=>{}});
|
||||
await observer.poll();assert.doesNotThrow(()=>observer.dispose());await new Promise(r=>setImmediate(r));
|
||||
});
|
||||
test('trusted programmatic submit without recent click or Enter is excluded',()=>{
|
||||
const f=fixture();f.run('install');const form=f.target('FORM');form.elements=[];f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
|
||||
f.emit('keydown',form,{key:'Enter'});f.advance(1100);f.emit('submit',form);assert.equal(f.run('drain').events.length,0);
|
||||
});
|
||||
@@ -5,6 +5,17 @@ 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';
|
||||
test('human-observer accepts observed click/submit only and uses its own audit source',async()=>{
|
||||
const policy={expiresAt:new Date(Date.now()+60000).toISOString(),evidence:{enabled:true,captureInteractions:true}},context={ready:true,deviceId:'device-a',policy};
|
||||
for(const action of ['click','submit']){
|
||||
const payload=buildInteractionEvidence({action,sessionId,url:'https://example.com/path?secret=1',sensitiveField:false,outcome:'observed',captureSource:'human-observer'},policy);
|
||||
assert.equal(payload.pageUrl,'https://example.com/');assert.equal(payload.outcome,'observed');
|
||||
for(const extra of [{outcome:'completed'},{action:'copy'},{bytes:1},{captureSource:'agent-adapter'}])assert.equal(validateInteractionEvidence({...payload,...extra}),false);
|
||||
const record={kind:'browser.interaction',eventId:sessionId,protectedEvidence:{deviceId:'device-a',type:'interaction',payload}},routes=[];
|
||||
assert.equal((await deliverAuditRecord({record,getContext:()=>context,send:async route=>{routes.push(route);return {id:sessionId,sourceEventId:1};}})).evidenceDelivered,true);
|
||||
assert.deepEqual(routes,['/api/device/events','/api/device/evidence']);record.kind='agent.action';await assert.rejects(deliverAuditRecord({record,getContext:()=>context,send:async()=>({})}),/source/);
|
||||
}
|
||||
});
|
||||
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']);});
|
||||
|
||||
@@ -2,6 +2,11 @@ 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';
|
||||
test('observed browser gestures export as information with unknown outcome',()=>{
|
||||
const e={id:2,time:'2026-10-13T12:00:00Z',kind:'browser.interaction',metadata:{interaction:{action:'submit',outcome:'observed',sessionId}}};
|
||||
const ecs=toECS(e);assert.deepEqual(ecs.event.type,['info']);assert.equal(ecs.event.outcome,'unknown');assert.equal(ecs.safetynet.interaction.outcome,'observed');assert.ok(toCEF(e).includes('cs3=observed'));
|
||||
for(const outcome of ['completed','authorized','denied']){e.metadata.interaction.outcome=outcome;assert.equal(toECS(e).safetynet.interaction,undefined);}
|
||||
});
|
||||
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);}});
|
||||
@@ -13,3 +13,18 @@ test('quarantine stops hidden and visible tabs, invalidates approvals and denies
|
||||
test('close destroys native contents and cannot revive closed tab capabilities',()=>{const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context();tabs.remove(a.id);assert.equal(a.view.webContents.destroyed,true);assert.equal(tabs.current(token),false);assert.equal(tabs.active(),b);assert.equal(tabs.fromContents(a.view.webContents.id),null);tabs.close();assert.equal(b.view.webContents.destroyed,true);assert.equal(disposed.length,2);assert.throws(()=>tabs.create());});
|
||||
test('URL mutation and renderer-chosen identifiers cannot reuse native approval',()=>{const {tabs}=fixture();const a=tabs.create();tabs.resume(1);const token=tabs.context();a.view.webContents.url='https://other.test/';assert.equal(tabs.current(token),false);assert.throws(()=>tabs.activate('renderer-forged-id'));assert.equal(Object.isFrozen(token),true);assert.notEqual(a.id,'managed');});
|
||||
test('capacity is bounded before another renderer process is created',()=>{const {tabs}=fixture({maxTabs:1});tabs.create();assert.throws(()=>tabs.create(),/capacity/);assert.equal(tabs.all().length,1);});
|
||||
|
||||
// Electron can remove view.webContents before pending startup rejects. The
|
||||
// native reference must survive solely for deterministic cleanup/revocation.
|
||||
test('destroyed native view remains removable after its live getter disappears',()=>{
|
||||
const {tabs,disposed}=fixture();const a=tabs.create(),b=tabs.create();tabs.resume(1);const token=tabs.context(a.id),contents=a.contents;
|
||||
assert.throws(()=>{a.contents=b.contents;},TypeError);
|
||||
assert.equal(Object.keys(a).includes('contents'),false);
|
||||
contents.destroyed=true;delete a.view.webContents;
|
||||
assert.equal(tabs.current(token),false);assert.throws(()=>tabs.context(a.id),/Unknown native tab/);
|
||||
assert.doesNotThrow(()=>tabs.remove(a.id));assert.equal(tabs.fromContents(contents.id),null);assert.equal(disposed.includes(a),true);assert.equal(tabs.active(),b);
|
||||
});
|
||||
test('shutdown cleans already destroyed views without reactivating surviving views',()=>{
|
||||
const {tabs,disposed,attached}=fixture();const a=tabs.create(),b=tabs.create();a.contents.destroyed=true;delete a.view.webContents;
|
||||
const attachments=attached.length;assert.doesNotThrow(()=>tabs.close());assert.equal(disposed.length,2);assert.equal(tabs.all().length,0);assert.equal(attached.length,attachments);assert.equal(b.contents.destroyed,true);assert.doesNotThrow(()=>tabs.close());
|
||||
});
|
||||
Reference in new issue
Block a user