diff --git a/README.md b/README.md index cd2f16c..4cdfe26 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ still open: a reproducible Chromium WebRTC renderer crash in initial blank iframes requires a verified native fix. Other platforms and broader features remain outside this milestone. +[Native transport auditing](docs/native-transport-observability.md) records +tested WebTransport lifecycle attempts without exposing raw browser debugging +APIs. Its observation channel preserves clipboard protection; unsupported +transport payloads remain blocked by the native relay boundary. + S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged. ## A look inside diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index d53a5d9..cb7442a 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -27,6 +27,10 @@ for(const name of ['browser.upload.lease','browser.upload.part','browser.upload. // Diagnostic reasons are fixed code identifiers, never exception messages, // policy rule text, request content or arbitrary caller-supplied labels. const reasons=new Set(['request-source-context-changed','header-source-context-changed','headers-inspection-timeout','request-inspection-timeout','upload-lease-lost','upload-lease-release-failed','invalid-source-owner','policy-changed']); +names.add('browser.transport'); +for(const operation of ['transport.install','transport.target','transport.dispose','transport.created','transport.closed','transport.established','transport.monitor-unavailable'])operations.add(operation); +// Keep the diagnostics cold path free of schema/SDK initialization. +for(const reason of ['transport-guard-unavailable','transport-observer-detached','transport-observer-command-timeout','transport-observer-overflow','transport-observer-audit-failed','transport-observer-unavailable','unexpected-webtransport-established'])reasons.add(reason); const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value; if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value; @@ -42,25 +46,27 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}} function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;} const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000}; -function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0; +function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0,generationOrdinal=0;const failureReasons=Object.create(null),failureFacts=[]; // Parser timing is untrusted metadata: validate the entire batch before creating // spans, and attach it only to a currently recording browser operation. function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return; const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs})); const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);} }catch{/* Diagnostics must never change inspection results. */}} - function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));} - function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} + function retire(current){if(!current)return;current.active=false;current.retiredAt=Date.now();for(const [request,attempt]of current.requests){attempt.cancelSignalled=true;const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);}for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));} + function failureReason(error,attempt){const code=error?.code;if(code==='SAFETYNET_DIAGNOSTICS_CANCELLED'||attempt.nativeCode==='SAFETYNET_DIAGNOSTICS_CANCELLED')return 'cancelled';if(code==='ETIMEDOUT'||error?.message==='Request timed out')return 'timeout';if(code==='ECONNREFUSED'||attempt.nativeCode==='ECONNREFUSED')return 'connection-refused';if(code==='ECONNRESET'||attempt.nativeCode==='ECONNRESET')return 'connection-reset';if(error?.message==='Concurrent export limit reached')return 'concurrency-limit';const status=Number.isInteger(code)?code:attempt.httpStatus;if(status>=300&&status<400)return 'http-redirect';if(status>=400&&status<500)return 'http-client';if(status>=500&&status<600)return 'http-server';return 'exporter-failure';} + function gatedExporter(exporter,current,signal){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}const attempt={startedAt:Date.now(),httpStatus:null,nativeCode:null,cancelSignalled:false};if(!current.attempts[signal])current.attempts[signal]=attempt;let finished=false;const done=result=>{if(finished)return;finished=true;if(current.attempts[signal]===attempt)delete current.attempts[signal];if(result.code!==0){current.failedExports++;lifetimeFailedExports++;const reason=failureReason(result.error,attempt);failureReasons[reason]=(failureReasons[reason]||0)+1;failureFacts.push({signal,generation:current.ordinal,reason,durationMs:Math.max(0,Date.now()-attempt.startedAt),activeAtCompletion:current.active,retirementOverlapped:!!current.retiredAt&¤t.retiredAt>=attempt.startedAt,cancelSignalled:attempt.cancelSignalled,httpStatus:attempt.httpStatus});if(failureFacts.length>32)failureFacts.shift();}callback(result);};try{exporter.export(fresh,done);}catch(error){done({code:1,error});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};} + function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false; - let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0}; - const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}} + let current;try{current={active:true,config,context:attributes(context),agents:new Set(),requests:new Map(),attempts:Object.create(null),ordinal:++generationOrdinal,failedExports:0}; + const agentFactory=async(protocol,signal)=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);return;}const attempt=current.attempts[signal];if(attempt){current.requests.set(request,attempt);request.once('close',()=>current.requests.delete(request));request.once('response',response=>{if(Number.isInteger(response.statusCode)&&response.statusCode>=100&&response.statusCode<=599)attempt.httpStatus=response.statusCode;});request.once('error',error=>{if(['SAFETYNET_DIAGNOSTICS_CANCELLED','ETIMEDOUT','ECONNREFUSED','ECONNRESET'].includes(error.code))attempt.nativeCode=error.code;});}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}} const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;}; - const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory}); + const base=config.endpoint.replace(/\/$/,'');const exporterConfig=(path,signal)=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:protocol=>agentFactory(protocol,signal)}); const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules(); const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode}); const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs}; - current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]}); - current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true; + current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces','traces')),current,'traces')})]}); + current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs','logs')),current,'logs')})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true; }catch{generation=null;retire(current);return false;} } function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return; @@ -95,6 +101,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio } async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);} async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);} - return {configure,span,spanSync,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})}; + return {configure,span,spanSync,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,failureReasons:{...failureReasons},failureFacts:failureFacts.map(fact=>({...fact}))})}; } module.exports={createDiagnostics,attributes,endpointAllowed}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index 700f88f..811778f 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -210,12 +210,12 @@ async function flushAudit(){ 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,origin){ +async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts,origin,transportFacts){ if(!device||!policy)return; // Operational correlation contains no rule text, hostname or forensic payload. try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{} if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;} - try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} + try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const transport=transportFacts?require('../../packages/core/transport-facts.cjs').transportFactsForEvent(kind,transportFacts):undefined;if(transportFacts&&!transport)throw Error('Transport audit metadata mismatch');const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(transport?{transport}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;} catch{quarantine('Security events cannot be persisted. Contact your administrator.');} } function auditAgent(event){ @@ -695,6 +695,28 @@ function installSiteHooks(tab){ audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);} }); } +function installTransportObserver(tab){ + const contents=tab.contents; + const current=context=>!!context&&context.deviceId===device?.id&&context.sessionId===tab.id&&tabs?.fromContents(contents.id)===tab&&!contents.isDestroyed(); + const unavailable=reason=>{clipboardFatal=true;quarantine('Managed transport monitoring failed. Restart this browser.');try{tab.security.transportObserver?.dispose();}catch{}return diagnosticSpan('browser.transport',{operation:'transport.monitor-unavailable',sessionId:tab.id,reason,status:'error'},()=>audit('transport.monitor-unavailable',reason));}; + tab.security.transportObserver=require('./native-transport-observer.cjs').createNativeTransportObserver({ + channel:require('./page-clipboard-guard.cjs').createNativeTransportChannel(contents), + getContext:()=>ready&&policy&&device&&localRelay&&Date.parse(policy.expiresAt)>Date.now()?Object.freeze({deviceId:device.id,policyVersion:policy.version,sessionId:tab.id,documentGeneration:tab.security.interactionDocumentGeneration}):null, + isCurrent:current, + traceOperation:(operation,callback,context)=>diagnosticSpan('browser.transport',{operation,sessionId:context?.sessionId||tab.id,policyVersion:context?.policyVersion||policy?.version},callback), + // Synchronous native alarm precedes the serialized durable audit queue. + onEstablished:context=>{if(current(context)){clipboardFatal=true;quarantine('An uninspectable transport connected. Restart this browser.');}}, + audit:async(facts,context)=>{ + if(!current(context))return; + const {host,...transport}=facts; + const eventId=await diagnosticSpan('browser.transport',{operation:`transport.${transport.phase}`,sessionId:tab.id,policyVersion:context.policyVersion},()=>audit(`transport.${transport.phase}`,undefined,host,undefined,undefined,undefined,undefined,undefined,{policyVersion:context.policyVersion},transport)); + if(typeof eventId!=='string')throw Error('Transport audit persistence failed'); + if(transport.phase==='established')await unavailable('unexpected-webtransport-established'); + }, + onFailure:unavailable + }); + return tab.security.transportObserver.install(); +} function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));} async function createNativeTabImpl(input={},controls={}){ if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request'); @@ -705,6 +727,8 @@ async function createNativeTabImpl(input={},controls={}){ await tab.view.webContents.loadURL('about:blank'); controls.assertCurrent?.(); await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();})); + controls.assertCurrent?.(); + await installTransportObserver(tab); controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.(); if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();emit();return state(); }catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;} @@ -811,7 +835,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=>{tab.security.interactionObserver?.dispose();disposePageClipboardGuard(tab.contents);}}); + 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();Promise.resolve(tab.security.transportObserver?.dispose()).catch(()=>{});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};}, diff --git a/apps/browser/native-transport-observer.cjs b/apps/browser/native-transport-observer.cjs new file mode 100644 index 0000000..06795f3 --- /dev/null +++ b/apps/browser/native-transport-observer.cjs @@ -0,0 +1,58 @@ +'use strict'; +const {randomUUID}=require('node:crypto'); +const phases=Object.freeze({'Network.webTransportCreated':'created','Network.webTransportClosed':'closed','Network.webTransportConnectionEstablished':'established'}); +const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; +const opaque=value=>typeof value==='string'&&value.length>0&&value.length<=128&&/^[a-zA-Z0-9._:-]+$/.test(value); +function transportHost(value){if(typeof value!=='string'||value.length>8192)throw Error('Transport metadata refused');const u=new URL(value);if(u.protocol!=='https:'||u.username||u.password||u.hostname.length>253)throw Error('Transport metadata refused');return u.hostname.toLowerCase();} + +// Observability only: Chromium's mandatory private proxy remains the release +// boundary. No generic command runner, raw protocol events or page bridge escape. +function createNativeTransportObserver({channel,getContext,isCurrent,audit,onFailure,onEstablished,traceOperation=(_stage,fn)=>fn(),commandTimeoutMs=5000,maxTargets=64,maxAttempts=256,maxAuditQueue=256}={}){ + if(!channel||['isHealthy','enableNetwork','autoAttachTargets','resumeTarget','subscribeMessage','subscribeDetach','release'].some(name=>typeof channel[name]!=='function')||[getContext,isCurrent,audit,onFailure,onEstablished,traceOperation].some(fn=>typeof fn!=='function')||!Number.isInteger(commandTimeoutMs)||commandTimeoutMs<50||commandTimeoutMs>10000||[maxTargets,maxAttempts,maxAuditQueue].some(n=>!Number.isInteger(n)||n<1||n>256))throw TypeError('Native transport observer callbacks required'); + const targets=new Map(),attempts=new Map(),setups=new Set();let owned=false,installed=false,disposed=false,failed=false,installPromise,auditTail=Promise.resolve(),queued=0,unsubscribeMessage,unsubscribeDetach; + const active=()=>owned&&!disposed&&!failed; + function failure(reason='transport-observer-unavailable'){if(disposed||failed)return;failed=true;installed=false;attempts.clear();targets.clear();try{Promise.resolve(traceOperation('transport.monitor-unavailable',()=>onFailure(reason))).catch(()=>{});}catch{} } + async function command(method,_parameters={},sessionId){if(!active())throw Error('Native transport monitor unavailable');let timer;try{return await Promise.race([(method==='Network.enable'?channel.enableNetwork(sessionId):method==='Target.setAutoAttach'?channel.autoAttachTargets(sessionId):channel.resumeTarget(sessionId)),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Object.assign(Error('Native transport command timeout'),{reason:'transport-observer-command-timeout'})),commandTimeoutMs);})]);}finally{clearTimeout(timer);}} + function context(){try{const value=getContext();if(!value)return null;const copy=Object.freeze({...value});if(!uuid.test(copy.sessionId)||!Number.isSafeInteger(copy.policyVersion)||copy.policyVersion<1||!isCurrent(copy))return null;return copy;}catch{return null;}} + function enqueue(row,phase){if(queued>=maxAuditQueue){failure('transport-observer-overflow');return;}const facts=Object.freeze({transportId:row.id,protocol:'webtransport',phase,realm:row.realm,sessionId:row.context.sessionId,connectionEstablished:row.established,host:row.host});queued++; + // Metadata retains the creation policy/owner across later navigation/policy + // changes. Rechecking release authority here would erase legitimate audit. + auditTail=auditTail.then(async()=>{await traceOperation('transport.'+phase,()=>audit(facts,row.context),row.context);}).catch(()=>failure('transport-observer-audit-failed')).finally(()=>queued--); + } + function event(_event,method,params,sessionId){if(!active())return; + if(method==='Target.attachedToTarget'){ + const id=params?.sessionId,type=params?.targetInfo?.type; + if(!opaque(id)||targets.has(id)){failure();return;}if(targets.size>=maxTargets){failure('transport-observer-overflow');return;} + const realm=['worker','shared_worker','service_worker'].includes(type)?'worker':type==='iframe'?'iframe':null; + if(!realm){failure();return;}targets.set(id,{realm}); + const work=Promise.resolve().then(()=>traceOperation('transport.target',async()=>{ + // Issue setup before resume, but do not await responses while the fresh + // target is waiting for startup. Chromium may defer them until resume. + const network=command('Network.enable',{maxPostDataSize:0},id),children=command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true},id); + await Promise.all([network,children,command('Runtime.runIfWaitingForDebugger',{},id)]); + })).catch(error=>failure(error.reason==='transport-observer-command-timeout'?'transport-observer-command-timeout':'transport-observer-unavailable')).finally(()=>setups.delete(work));setups.add(work);return; + } + // Target detachment can precede its final Network close event. Keep the + // already captured bounded attempt correlation until that event or disposal. + if(method==='Target.detachedFromTarget'){targets.delete(params?.sessionId);return;} + const phase=phases[method];if(!phase)return; + if(sessionId&&!targets.has(sessionId)&&phase==='created'){failure();return;}if(!opaque(params?.transportId)){failure();return;} + const key=(sessionId||'window')+':'+params.transportId; + if(phase==='created'){ + if(attempts.has(key)){failure();return;}if(attempts.size>=maxAttempts){failure('transport-observer-overflow');return;}const owner=context();if(!owner)return; + try{const row={id:randomUUID(),context:owner,host:transportHost(params.url),realm:sessionId?targets.get(sessionId).realm:'window',target:sessionId,established:false};attempts.set(key,row);enqueue(row,phase);}catch{failure();}return; + } + const row=attempts.get(key);if(!row)return; + if(phase==='established'){if(row.established){failure();return;}row.established=true;try{onEstablished(row.context);}catch{failure();}enqueue(row,phase);return;} + attempts.delete(key);enqueue(row,phase); + } + function detach(){if(!disposed&&owned)failure('transport-observer-detached');owned=false;installed=false;} + async function install(){if(installPromise)return installPromise;if(disposed||failed||!channel.isHealthy())throw Error('Private transport observer unavailable'); + // eslint-disable-next-line preserve-caught-error -- Expose only a fixed error; protocol causes may contain private URLs or target data. + installPromise=Promise.resolve(traceOperation('transport.install',async()=>{try{unsubscribeMessage=channel.subscribeMessage(event);unsubscribeDetach=channel.subscribeDetach(detach);owned=true;await command('Network.enable',{maxPostDataSize:0});await command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true});if(!active())throw Error('Native transport monitor unavailable');installed=true;}catch(error){failure(error.reason==='transport-observer-command-timeout'?'transport-observer-command-timeout':'transport-observer-unavailable');throw Error('Native transport monitor installation failed');}}));return installPromise; + } + function dispose(){if(disposed)return;disposed=true;installed=false;attempts.clear();targets.clear();try{unsubscribeMessage?.();}catch{}try{unsubscribeDetach?.();}catch{}try{channel.release();}catch{}owned=false;} + async function flush(){await Promise.allSettled([...setups]);await auditTail;} + return {install,dispose,flush,status:()=>Object.freeze({installed,failed,disposed,attached:owned&&channel.isHealthy(),targets:targets.size,pending:attempts.size,queued})}; +} +module.exports={createNativeTransportObserver}; diff --git a/apps/browser/page-clipboard-guard.cjs b/apps/browser/page-clipboard-guard.cjs index 6a734cc..25f6397 100644 --- a/apps/browser/page-clipboard-guard.cjs +++ b/apps/browser/page-clipboard-guard.cjs @@ -24,7 +24,7 @@ function deadline(operation){let timer;return Promise.race([operation,new Promis async function attachPageClipboardGuard(contents,onFailure){ const api=contents.debugger; if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable'); - const owner={api,healthy:false}; + const owner={api,healthy:false,onFailure}; const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();}; owner.detached=detached; api.on('detach',detached); @@ -49,9 +49,41 @@ function guardedScreenshot(contents,parameters){ } function disposePageClipboardGuard(contents){ const owner=owners.get(contents);if(!owner)return; + owner.releaseTransport?.(); owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached); if(owner.api.isAttached())owner.api.detach(); } +function createNativeTransportChannel(contents){ + const owner=owners.get(contents),api=owner?.api; + if(!owner?.healthy||api!==contents.debugger||!api.isAttached()||owner.transportLease)throw Error('Private transport channel unavailable'); + const lease=Symbol('native-transport'),subscriptions=new Set(),targets=new Set();let released=false; + owner.transportLease=lease; + const healthy=()=>!released&&owners.get(contents)===owner&&owner.healthy&&owner.transportLease===lease&&!contents.isDestroyed()&&api.isAttached(); + const session=value=>value===undefined||typeof value==='string'&&value.length>0&&value.length<=128&&/^[a-zA-Z0-9._:-]+$/.test(value); + const command=(method,parameters,sessionId)=>{if(!healthy()||!session(sessionId))return Promise.reject(Error('Private transport channel unavailable'));return api.sendCommand(method,parameters,sessionId);}; + const subscribe=(name,listener)=>{if(!healthy()||typeof listener!=='function')throw Error('Private transport subscription unavailable');api.on(name,listener);let removed=false;const remove=()=>{if(removed)return;removed=true;api.removeListener(name,listener);subscriptions.delete(remove);};subscriptions.add(remove);return remove;}; + const messages=new Set(['Target.attachedToTarget','Target.detachedFromTarget','Network.webTransportCreated','Network.webTransportConnectionEstablished','Network.webTransportClosed']); + function release(){ + if(released)return;released=true;for(const remove of [...subscriptions])remove(); + if(owner.transportLease===lease){owner.transportLease=null;owner.releaseTransport=null;} + // Release private observation without detaching the clipboard security owner. + // Fresh workers may still be waiting for startup; resume before dropping targets. + if(api.isAttached()){ + const send=(method,parameters,id)=>{try{return Promise.resolve(api.sendCommand(method,parameters,id));}catch{return Promise.reject(Error('Private transport cleanup unavailable'));}}; + const pending=[];for(const id of targets)pending.push(send('Runtime.runIfWaitingForDebugger',{},id)); + pending.push(send('Target.setAutoAttach',{autoAttach:false,waitForDebuggerOnStart:false,flatten:true})); + void Promise.allSettled(pending).then(results=>{if(results.some(result=>result.status==='rejected')&&owners.get(contents)===owner&&owner.healthy&&!contents.isDestroyed())owner.onFailure();}); + } + targets.clear(); + } + owner.releaseTransport=release; + return Object.freeze({isHealthy:healthy, + enableNetwork:sessionId=>command('Network.enable',{maxPostDataSize:0},sessionId), + autoAttachTargets:sessionId=>command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true},sessionId), + resumeTarget:sessionId=>sessionId===undefined?Promise.reject(Error('Private transport target required')):command('Runtime.runIfWaitingForDebugger',{},sessionId), + subscribeMessage:listener=>subscribe('message',(event,method,params,sessionId)=>{if(!messages.has(method))return;if(method==='Target.attachedToTarget'&&session(params?.sessionId)&¶ms.sessionId!==undefined)targets.add(params.sessionId);if(method==='Target.detachedFromTarget')targets.delete(params?.sessionId);listener(event,method,params,sessionId);}), + subscribeDetach:listener=>subscribe('detach',()=>listener()),release}); +} async function guardedVisualStructure(contents){ const owner=owners.get(contents); if(!owner?.healthy||!owner.api.isAttached()||contents.isDestroyed())throw Error('Managed visual export refused'); @@ -71,4 +103,4 @@ async function guardedVisualStructure(contents){ } return {safe:true,nodes}; } -module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard}; +module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard,createNativeTransportChannel}; diff --git a/apps/server/ai-gateway.mjs b/apps/server/ai-gateway.mjs index d42ba07..38bbb92 100644 --- a/apps/server/ai-gateway.mjs +++ b/apps/server/ai-gateway.mjs @@ -16,18 +16,19 @@ export function approvedAIEndpoint(policy,config){ } export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}){ return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{ - const controller=new AbortController(); - const cancel=()=>controller.abort(); + const controller=new AbortController();let cancellationError; + const abortWith=error=>{cancellationError??=error;controller.abort();}; + const cancel=()=>abortWith(rejected(403,'AI authorization changed during processing','ai-authorization-changed')); signal?.addEventListener('abort',cancel,{once:true}); if(signal?.aborted)cancel(); function assertCurrent(){ const active=store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(deviceId); const policyChanged=active&&!isDeepStrictEqual(store.effectivePolicy(deviceId),policy); if(controller.signal.aborted||!active||Date.parse(policy.expiresAt)<=Date.now()||policyChanged){ - controller.abort();throw rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed'); + abortWith(rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed'));throw cancellationError; } } - const deadline=setTimeout(()=>controller.abort(),60000); + const deadline=setTimeout(()=>abortWith(rejected(504,'AI request timed out','ai-request-timeout')),60000); const monitor=setInterval(()=>{try{assertCurrent();}catch{/* Abort pending provider work; release checks report the fixed failure. */}},100); monitor.unref();deadline.unref(); try{ @@ -62,7 +63,7 @@ export async function runApprovedAI({store,config,deviceId,policy,prompt,signal} const output=await traceOperation('ai.inspect.output',{bytes:Buffer.byteLength(answer),policyVersion:policy.version},()=>scan(answer));store.event(deviceId,'ai.output-decision',{...output,policyVersion:policy.version}); if(output.decision==='block'){store.event(deviceId,'ai.output-block',{ruleId:output.ruleId,policyVersion:policy.version});throw rejected(403,'AI response blocked by company policy',output.ruleId);} assertCurrent();return answer; - }catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw e;} + }catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw cancellationError||e;} finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();} }); } diff --git a/apps/server/app.mjs b/apps/server/app.mjs index 3b20abd..f1760b0 100644 --- a/apps/server/app.mjs +++ b/apps/server/app.mjs @@ -4,6 +4,7 @@ import transferFacts from '../../packages/core/transfer-facts.cjs'; import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs'; import agentAudit from '../../packages/core/agent-audit.cjs'; import interactionAudit from '../../packages/core/interaction-facts.cjs'; +import transportAudit from '../../packages/core/transport-facts.cjs'; import {installEvidence} from './evidence.mjs'; import {installIncidents} from './incidents.mjs'; import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs'; @@ -107,7 +108,11 @@ 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(['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.submission-completed','vault.submission.failure','vault.submission-prepared','vault.submission-authorized','vault.submission-denied','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); + const b=z.object({eventId:z.string().uuid(),kind:z.enum([...transportAudit.TRANSPORT_KINDS,'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.submission-completed','vault.submission.failure','vault.submission-prepared','vault.submission-authorized','vault.submission-denied','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(),transport:transportAudit.TransportFactsSchema.optional()}).strict().parse(req.body); + if(b.kind.startsWith('transport.')){ + const unavailable=b.kind==='transport.monitor-unavailable'; + if(b.file||b.request||b.agent||b.interaction|| (unavailable?(b.host!==undefined||b.transport!==undefined||!transportAudit.TRANSPORT_MONITOR_REASONS.includes(b.ruleId)):(b.ruleId!==undefined||!transportAudit.canonicalTransportHostname(b.host)||!transportAudit.transportFactsForEvent(b.kind,b.transport))))return res.status(400).json({error:'Transport audit events require matching lifecycle metadata'}); + }else if(b.transport!==undefined)return res.status(400).json({error:'Transport facts require a matching transport event'}); 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'}); @@ -269,7 +274,7 @@ export function createApp(store,config) { app.use((error,_req,res,_next)=>{ config.onError?.(error); const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed'; - res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})}); + res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409,504].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})}); }); return app; } diff --git a/docs/electron-best-practices.md b/docs/electron-best-practices.md index f69134b..6763de6 100644 --- a/docs/electron-best-practices.md +++ b/docs/electron-best-practices.md @@ -84,9 +84,55 @@ that the whole application is clean, fully optimized or independently audited. ## Latest reproducible checks -- Full source suite: 749 tests passed, no failures or skips; see the ignored - `artifacts/mvp-final-startup-suite.log`. `npm run lint` passed again with - zero warnings on 2026-10-13. +A subsequent full run after the Established-alarm regression added a test +passed 764 of765 and exposed a real gateway cancellation race: the policy +monitor aborted the provider but discarded its fixed409 error, producing500. +The gateway now preserves the first fixed cancellation reason (policy409, +caller403, deadline504). Fourteen focused tests and scoped lint passed. Actual +collector trace `3b6b5e374da506f7ae5ffbb118933bf6` shows the provider aborted at +101.6ms under the gateway and library parents. The earlier failed full run is +retained in `artifacts/transport-owner-containment-final-suite.log`. + +The subsequent native clipboard/upload scenario passed all17 enforcement +checks but failed diagnostics acceptance because lifetime exports recorded two +errors. Collector rejected writes remained0; their exact causes cannot be +reconstructed from the original counters. Failure instrumentation and a fresh +acceptance run must resolve this separately from DLP transmission proof. + +The browser now retains at most32 cloned export-failure facts and lifetime +totals by fixed reason. Facts include traces/logs, generation, duration, fixed +HTTP status, retirement overlap and explicitly signalled cancellation. Raw +error text, response bodies, credentials and collector URLs are excluded. +Actual held HTTP exports prove that retirement cancellation remains counted +as failure; the instrumentation does not silently reset or retry operations. +Seventeen focused diagnostics tests passed, including timeout, refusal,503, +bounded history and concurrent flush attribution. + +A fresh public-only native diagnostic run completed with four independently +reviewed policy publications and four public HTTPS submissions. Every snapshot +had zero current/lifetime failures and an empty failure history; actual exported +trace `b5eefaf6bb8407f6917bd5cada6e8587` and collector rejectedWrites0 were +verified. Reproduction: `node scripts/native-diagnostics-public.mjs` with the +protected local collector already running. This does not retrospectively prove +the cause of the earlier two failures or reset their historical evidence. + +A further quiet public-only native measurement delivered80 benign HTTPS +responses across four reviewed publications: median59.53ms, p95 83.19ms, +max88.17ms; observed main event-loop maximum drift328.29ms over637 samples. +Current/lifetime failures remained0, collector rejectedWrites0. Trace +`d4689e3d28ebe902b752500c0f79e30b` and +`artifacts/public-backpressure-native-proof.json` are local evidence. This does +not resolve or causally explain the semantic run's three timeouts and one +concurrency failure. A bounded export queue, single-flight flush and collector +commit-latency measurements are the next narrowly scoped diagnostics work; +mandatory inspection and audit durability must remain intact. + +- Full source suite: 771 tests passed with `--test-concurrency=2`, no failures + or skips; see `artifacts/transport-owner-final-failure-ledger-suite.log`. An earlier + higher-concurrency run failed one evidence-key ACL check; its cause remains + unconfirmed and its log is retained. All 18 evidence tests passed separately. + No ACL protection or timeout was weakened. `npm run lint` passed with zero + warnings after the final source changes. - A fresh `npm audit --omit=dev --json` reported zero advisories. Development toolchain findings remain tracked separately in `dependency-audit.md`. - In the paired single module-load profiles, diagnostics require time changed @@ -95,7 +141,11 @@ that the whole application is clean, fully optimized or independently audited. measurements do not establish full application startup or percentile gains. - Native Windows DLP fixtures delivered public data and blocked confidential literal and semantic payloads. Restart reused the protected device profile. - These are separate from final packaged-artifact acceptance. + These are separate from packaged-artifact acceptance. The final literal and + actual-restart fixtures pass17+5 checks with zero lifetime exports failed. + The latest semantic receiver/model checks pass, but diagnostics acceptance + fails on three measured2s timeouts and one concurrency rejection. These are + retained as real failures, not cancelled exports or successful delivery. - The protected local OpenTelemetry receiver reported zero rejected writes. Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request, browser.inspect.local, inspector.inspect, semantic load/inference and durable @@ -111,6 +161,12 @@ that the whole application is clean, fully optimized or independently audited. - The full enrolled browser WebTransport receiver fixture now passes through reviewed DIRECT and fetched HTTPS PAC policies: zero managed packets/bytes, paired against 132 baseline bytes, with ordinary HTTPS submissions working. - It records source hashes and genuine operational traces. There is still no - per-attempt WebTransport security event because this path produces no - Electron webRequest callback; transport denial and audit coverage are separate. + It records source hashes and genuine operational traces. A fixed private + native channel now shares the existing clipboard debugger owner and records + six Created and six Closed security events across the tested window and + dedicated-worker attempts, with zero Established events. All twelve events + correlate with exported audit traces. No raw URL, payload or CDP identifier + enters these events. See [native transport observability](native-transport-observability.md) + for coverage limits, failure behavior and ECS/CEF mapping. The private relay + remains the transmission boundary; lifecycle observations alone do not prove + content inspection or universal realm coverage. diff --git a/docs/electron-security-review.md b/docs/electron-security-review.md index 8bac1d9..469cee9 100644 --- a/docs/electron-security-review.md +++ b/docs/electron-security-review.md @@ -2,6 +2,12 @@ This is a read-only review of the current source and unpacked Windows executable. It is not a penetration test or a claim of complete Chromium DLP coverage. +Later entries record implementation and repeated native verification following +this initial review. For current WebTransport per-attempt auditing and its +coverage limits, use [native transport observability](native-transport-observability.md). +Historical missing-event findings below have been superseded by that work; +the separate native WebRTC engine crash remains an open acceptance gate. + ## Official references Electron recommends isolated, sandboxed remote renderers, constrained IPC and navigation, current dependencies, and consideration of custom protocols and packaging fuses. [Security](https://www.electronjs.org/docs/latest/tutorial/security). @@ -81,6 +87,15 @@ Inspection/inference runs outside the main process, but synchronous audit encryp ### Final package follow-up +The subsequent shared clipboard/transport owner implementation also passed +actual Windows package/runtime and fuse negative verifiers. Its enrolled native +fixture now proves six Created and six Closed records, zero Established records, +and twelve correlated lifecycle/audit traces. Clipboard/restart and actual local +Gemma regressions passed with matching source hashes. This supersedes the +transport audit-gap statements below for the specifically tested Window and +dedicated Worker cases; it is not universal transport or payload coverage. +See [native transport observation](native-transport-observability.md). + The new frozen-source Windows build and both package verifiers passed after the startup correction. Actual baseline, RUN_AS_NODE, NODE_OPTIONS, CLI inspector, eleven certificate/feature override launches and copied-ASAR tampering diff --git a/docs/local-dlp-relay.md b/docs/local-dlp-relay.md index 639e25e..4ee5221 100644 --- a/docs/local-dlp-relay.md +++ b/docs/local-dlp-relay.md @@ -28,9 +28,12 @@ Native evidence: `artifacts/webtransport-native-proof.json`. The receiver got zero packets/bytes with the private relay while ordinary HTTP loaded. The Windows core DLP and real restart fixtures also passed with relay integration. Chromium upgrades must repeat this receiver proof; it is version-specific. -WebTransport-specific denial audit events are not yet observed because the -native rejection occurs before existing HTTP/relay callbacks. Do not claim -complete transport-attempt SIEM coverage from the ordinary DLP event pipeline. +The native rejection still occurs before HTTP/relay callbacks. A private native +observer now supplies real lifecycle records for the tested Window/dedicated +Worker paths through enrollment and policy changes. See +[native transport observation](native-transport-observability.md). These are +Created/Closed observations, not inspected-payload decisions or universal +transport coverage. The relay remains the release boundary. Inspected WebSocket/WebRTC support remains the intended compatibility goal. Uninspected transports remain restricted until their content gates have their diff --git a/docs/native-runtime/README.md b/docs/native-runtime/README.md index 342f067..374dfd3 100644 --- a/docs/native-runtime/README.md +++ b/docs/native-runtime/README.md @@ -105,3 +105,80 @@ attempts; its tagged binder source lacks the no-op implementation. A later binary is acceptable only after source inclusion checks and all native gates above. Current ignored artifacts retain the original crashes and positive controls; an upgrade must not overwrite that evidence. + +## Local setup started, 2026-10-13 + +An isolated checkout now exists at +`C:/Users/karl/Safetynet/artifacts/native-build/electron44`. +Electron tag44.7.0 resolved to commit +`61f55c4ce540416b3e43f75f6c0f125d22e5e908`; its DEPS pins152.0.7977.130. +The adjacent depot_tools checkout is pinned at +`52ae103994896b9ce7742a605de8f43b410e5515`. +The ignored `run-sync.mjs` wrapper supplies process-only PATH/toolchain/Git +configuration and runs `gclient.bat sync --nohooks --shallow --jobs=2 +--revision=src@2c592105bbcd9490a9894df48d0fe59b2c512651`. +It records `source-sync.log`, stops its own process tree if free disk falls below +150GiB, and bounds each run to30minutes. A live bootstrap is not source completion. +Current depot_tools expects `git.bat`; the first sync failed because only +`git.exe` was installed. An isolated `artifacts/native-build/bin/git.bat` now +forwards to the existing signed `C:/Program Files/Git/cmd/git.exe`; only the +wrapper's PATH includes that shim. The initial Chromium clone observed after +retry did not include a depth argument, so the requested shallow option must +not be treated as proof of a shallow fetch. Disk/deadline bounds still apply. +Hooks, patch application against Electron's full source, compilation and native +acceptance remain pending. + +The controlled tagged bootstrap completed on2026-10-14 with exit0. Chromium +`152.0.7977.130` resolved to +`2c592105bbcd9490a9894df48d0fe59b2c512651`; Git reports a shallow repository. +Electron's pinned nested checkout was preserved. The backport's `git apply +--check` also passed against this **complete pristine Chromium checkout**. +Electron's dependency synchronization and native patch hooks have not yet +completed, so this does not replace the required check after Electron patches. +The bootstrap wrapper is `artifacts/native-build/run-bootstrap.mjs`; its log is +`bootstrap.log`. Initial fetch transferred a1.43GB pack; free disk after checkout +was259.3GiB. No compiler or native test was executed at this milestone. + +The first dependency-sync attempt after bootstrap attempted a broad Chromium +remote fetch because the DEPS revision was a textual tag. That owned process +tree was explicitly stopped; its terminal exit was1. The matching local tag +was installed and the wrapper now overrides `src` with the exact existing +commit above, without modifying DEPS. The next run advanced into dependencies: +Node's `v24.21.0` checkout completed at +`955266bfdd854cd280dffd47548673914484e4c0`, followed by other Chromium +dependencies. At this documentation snapshot the bounded dependency run is +still active: dependency completion, patch hooks and compilation are **not +verified**. Gclient initial dependency clones may still fetch full histories +despite the shallow option; the disk floor and run deadline remain mandatory. + +The same dependency sync was subsequently stopped deliberately to free resources +for failed application-test diagnosis. Its terminal exit was1; the owned process +tree was verified and stopped, without starting another sync. Chromium, Electron +and Node pins remain unchanged. Dawn, DevTools, ICU and Ink checkouts are present; +V8 and WebRTC dependency checkouts are still absent. Free disk at this checkpoint +was246.2GiB. This is a resumable preparation checkpoint, not a completed build. + +### Missing ATL/MFC: explicit administrator step + +Inspection found the existing installer at +`C:/Program Files (x86)/Microsoft Visual Studio/Installer/setup.exe`, version +4.10.30.62513. Its Authenticode status was **Valid**, signer Microsoft Corporation. +Existing installation: +`C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools`. +The official required component ID is +`Microsoft.VisualStudio.Component.VC.ATLMFC`. + +The current agent process is not elevated. No UAC bypass or installer launch was +attempted. To install only this missing component, a user can run the following +in an **administrator PowerShell** and allow the normal installer flow: + +```powershell +& 'C:/Program Files (x86)/Microsoft Visual Studio/Installer/setup.exe' modify ` + --installPath 'C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools' ` + --add Microsoft.VisualStudio.Component.VC.ATLMFC --passive --norestart +``` + +Verify afterward using `vswhere.exe -products '*' -requires +Microsoft.VisualStudio.Component.VC.ATLMFC -property installationPath`. +Treat installer exit3010 as restart required; do not claim build readiness until +the component is detected and toolchain checks pass. diff --git a/docs/native-transport-observability.md b/docs/native-transport-observability.md new file mode 100644 index 0000000..e06ce28 --- /dev/null +++ b/docs/native-transport-observability.md @@ -0,0 +1,76 @@ +# Native WebTransport security observation + +The private HTTP relay is the release boundary. Observation does not inspect +WebTransport payloads and cannot replace native transport containment. + +Electron webRequest does not observe the tested Chromium WebTransport path. +The browser now subscribes privately to native Created, Established and Closed +events through the existing clipboard security owner's CDP connection. +The [Electron debugger API](https://www.electronjs.org/docs/latest/api/debugger) +and [Chromium Network protocol](https://chromedevtools.github.io/devtools-protocol/tot/Network/) +describe this instrumentation. No debugger port or protocol dispatcher is +exposed to websites, UI JavaScript or local agents. + +## Ownership and lifecycle + +The clipboard guard owns the only debugger attachment. Its fixed transport +lease can enable network observation, attach related targets and resume startup +targets; it cannot submit arbitrary commands or detach clipboard protection. +Initial about:blank navigation completes before setup commands, which Chromium +can otherwise defer indefinitely. Setup finishes before real page navigation. +Worker observation is queued before startup resume, covering the tested worker's +first statement. Disposal unsubscribes observation, resumes waiting targets and +releases auto-attachment while preserving clipboard protection. + +Commands, targets, pending attempts and audit queues are bounded. Unexpected +detach, command timeout, overflow or audit failure makes the browser remain +quarantined until restart. Unexpected native establishment also quarantines +synchronously in the native event handler, before entering the serialized audit +queue. A held Created-audit regression verifies this ordering. This is a containment alarm; +it is not proof that an observer could prevent already transmitted bytes. + +## Security records + +`transport.created`, `transport.closed` and `transport.established` carry an +application-generated UUID, protocol, phase, realm, associated native tab UUID, +connection-established flag and canonical destination hostname. Creation policy +and owner context survive subsequent navigation or policy changes. Native CDP +identifiers, URLs, query strings, initiators, certificates, headers and bodies +are absent. Backend validation rejects contradictory phases, extra fields and +noncanonical host data. Monitor failures accept only fixed reason identifiers. + +Records use the protected durable event pipeline. ECS and CEF preserve the +lifecycle facts as observations with unknown outcome: a Closed event is not +automatically claimed as an inspected or blocked payload. Operational traces +use fixed browser.transport stages and correlate audit encryption, persistence +and delivery without destination or content attributes. The signed diagnostics +switch still controls operational exports independently of security auditing. + +## Actual Windows evidence + +The current shared-owner native fixture proves Window and first-statement +dedicated Worker behavior through enrollment, reviewed DIRECT and fetched HTTPS +PAC→DIRECT policies. The unguarded control delivered 132 bytes. Managed cases +delivered zero packets or bytes, while three public HTTPS submissions worked. +Six Created and six Closed records reached the central backend; none Established. +All twelve exported phase traces have matching audit-stage parents. Ordinary +webRequest callbacks remain zero, so these are actual native observations. + +Evidence: ignored `artifacts/fullmain-webtransport-native-proof.json`, source +hashes and `artifacts/cdp-webtransport-native-proof.json`. The latter additionally +checks real DevTools coexistence and verifies that observer disposal preserves +clipboard ownership. Earlier literal/restart and real local Gemma regressions +passed on the same source hashes. The later final-source literal/restart test +also passes; the later semantic DLP checks pass but its debug acceptance records +three export timeouts and one concurrency rejection. Those failures remain +open and are separate from receiver containment. These scenarios do not prove every worker, iframe, +shared-worker attribution or future Chromium transport implementation. + +## Measured limits + +During concurrent native source checkout/build work, semantic paste took +2.9–3.7 seconds and public-file inspection 14.3 seconds. Protected audit fsync +reached 414 ms. These traces identify work to profile; they do not isolate an +observer regression or justify weakening scanning, isolation or persistence. +The separate initial-about:blank WebRTC engine crash remains unresolved until a +fixed native runtime is compiled and passes its paired receiver tests. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index e29b50b..434e633 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -7,6 +7,18 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap. ## Aktuelle nächste Abnahmen +**Aktualisierung des Windows-MVP:** 771 Tests bestehen mit begrenzter +Parallelität. Der aktuelle Windows-Build besteht Paket-, Fuse- und +Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests, +Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen +Gemma-DLP-Prüfungen bestehen, ihre Debug-Abnahme scheitert noch an drei +Export-Timeouts und einer Concurrency-Ablehnung. WebTransport-Versuche in Fenster und Dedicated Worker sind nun +als korrelierte Security-Events und OpenTelemetry-Traces nachgewiesen; siehe +[Transport-Nachweise](native-transport-observability.md). Der bekannte native +WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist. +Die folgenden älteren Integrationszahlen beschreiben historische Zwischenstände; +die aktuelle verbindliche Abnahme steht im [Windows-MVP-Ziel](windows-dlp-mvp.md). + Aktueller Integrationsstand: 713 Tests bestehen (`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft: diff --git a/docs/windows-dlp-mvp.md b/docs/windows-dlp-mvp.md index bcf04ba..d7fdf67 100644 --- a/docs/windows-dlp-mvp.md +++ b/docs/windows-dlp-mvp.md @@ -48,8 +48,34 @@ erlaubte Ziele gelangen. Erst nach der folgenden Abnahme wird das MVP abgeschlos ## Aktueller Abnahmestand -749 Tests bestehen im letzten vollständigen Lauf; ESLint verlangt null Fehler -und null Warnungen. Nachweis: `artifacts/mvp-final-startup-suite.log`. +**Letzter Quellstand:** Windows-Paket und beide Paketverifikationen bestehen; +Authenticode bleibt `NotSigned`. Native Clipboard-/Upload- und echte Neustarttests +bestehen erneut mit17+5 Prüfungen,13 öffentlichen Requests, null vertraulichen +Requests und zwei exakt übertragenen Dateien. Aktuelle und lebenslange +Exportfehler bleiben in diesen Prozessen0. Der frische öffentliche Diagnosetest +mit vier geprüften Policy-Veröffentlichungen besteht ebenfalls. + +**Noch offen:** Die letzte echte lokale Gemma-Abnahme besteht alle vier +DLP-/Empfängerprüfungen, scheitert aber an drei gemessenen2s-Export-Timeouts und +einem `concurrency-limit`. Die neuen begrenzten Fehlerdaten belegen aktive +Generation1 ohne Policy-Abbruch; sie beweisen noch nicht die Ursache der +Antwortverzögerung. Kein Zähler wurde gelöscht oder eine Erwartung gelockert. +Für die Debug-Abnahme sind begrenzte Export-Warteschlangen, ein gemeinsamer +Flush und Latenz-/Eventloop-Nachweise zu prüfen. Ein weiterer geschützter +Transporttest wurde nach diesem Fehler nicht einfach wiederholt. +Der native WebRTC-Absturz bleibt ein separater offener Stabilitäts-Gate. +Das MVP ist deshalb weiterhin **nicht abgeschlossen**. + +771 Tests bestehen im letzten vollständigen Lauf mit zwei parallelen Testdateien; +ESLint verlangt null Fehler und null Warnungen. Nachweis: +`artifacts/transport-owner-final-failure-ledger-suite.log`. Ein späterer +Policy-Abbruch verlor ursprünglich seinen festen409-Fehler und lieferte500; +dieser echte Gateway-Fehler ist mit deterministischen Tests und exportierten +Traces behoben. Der stärker parallelisierte +Lauf scheiterte an einer Evidence-Schlüssel-Rechteprüfung. Die kontrollierte +ACL-Nachprüfung war geschützt; alle 18 Evidence-Tests bestanden anschließend +separat. Die ursprüngliche Ausnahme enthält keine ausreichende Ursache, um +einen Timeout rückwirkend als bewiesen zu behaupten. Kein Schutz wurde gelockert. Die neue native Windows-Abnahme besteht mit 17 Checks, einschließlich echtem Maus-Klick im contenteditable-Editor, Klartext/HTML-Clipboard, Policy-Race, YARA-/ICAP-Block und Scanner-Ausfall. Danach startete ein anderer Browserprozess @@ -61,7 +87,11 @@ mit null Exportfehlern. Nachweise: `artifacts/windows-dlp-mvp-process-restart-su `windows-dlp-mvp-first-process-native-proof.json` und `windows-dlp-mvp-restart-native-proof.json`. Der aktuelle integrierte Relay-/Proxy-/Preload-Stand bestand diese native -Abnahme erneut: `artifacts/windows-dlp-mvp-current-frozen-native.log`. +Abnahme erneut. Der gemeinsame Clipboard-/Transport-Owner bestand danach +wieder alle 17 plus fünf echten Neustart-Checks: +`artifacts/transport-owner-mvp-native.log`. Auch die echte lokale Gemma-Abnahme +bestand erneut: `artifacts/transport-owner-semantic-native.log`; Quellhashes +blieben unverändert. Das Gateway prüft normalisierte Textdarstellungen einzeln und revalidiert Policy/Widerruf vor Versand und Ausgabe. Tatsächliche ICAP-/Provider-Tests @@ -90,8 +120,13 @@ enrollte Browser übertrug null Pakete und Bytes, auch nach reviewed DIRECT und HTTPS-PAC→DIRECT-Policywechseln. Drei gewöhnliche HTTPS-POSTs funktionierten. Nachweis: `artifacts/fullmain-webtransport-native-proof.json`, mit genauen Quellhashes; spätere Änderungen betreffen ausschließlich Startparameterprüfung. -Die vor HTTP blockierten Versuche liefern keine webRequest-Callbacks und -damit weiterhin keine einzelnen DLP-Audit-Ereignisse. Diese SIEM-Grenze bleibt offen. +Die vor HTTP blockierten Versuche liefern weiterhin keine webRequest-Callbacks. +Der neue private native Observer erfasst jedoch tatsächlich sechs Created- und +sechs Closed-Lebenszyklen mit korrelierten Traces und ECS-/CEF-Metadaten. +Er teilt den begrenzten Kanal des Clipboard-Owners und trennt dessen Schutz +nicht. Die Reichweite ist Window und getesteter Dedicated Worker, keine +universelle Realm- oder Nutzlastprüfung. Details: +[Native Transportbeobachtung](native-transport-observability.md). Ältere fehlgeschlagene Testläufe bleiben als solche erhalten. ## Verbleibende Abnahmen diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs index db62e04..39dd76d 100644 --- a/packages/core/siem.mjs +++ b/packages/core/siem.mjs @@ -1,26 +1,27 @@ import interactionAudit from './interaction-facts.cjs'; +import transportAudit from './transport-facts.cjs'; import agentAudit from './agent-audit.cjs'; import transferFacts from './transfer-facts.cjs'; import requestTransferFacts from './request-transfer-facts.cjs'; const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;}; const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;}; export function toECS(event,installationId='safetynet') { - const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; + const m=event.metadata,transport=transportAudit.canonicalTransportHostname(m.host)?transportAudit.transportFactsForEvent(event.kind,event.metadata.transport):null,transportEvent=transportAudit.TRANSPORT_KINDS.includes(event.kind),interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert'; const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked'; const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.'); return { '@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||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)}:{})}, + type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||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}}:{}), + ...(m.actor?{user:{id:m.actor}}:{}),...(m.host&&(!transportEvent||transport)?{destination:{domain:m.host}}:{}), ...(m.ruleId?{rule:{id:m.ruleId}}:{}), ...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}), ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}), ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}), - safetynet:{...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null), + safetynet:{...(transport?{transport:{transport_id:transport.transportId,protocol:transport.protocol,phase:transport.phase,realm:transport.realm,session_id:transport.sessionId,connection_established:transport.connectionEstablished}}:{}),...(event.kind==='transport.monitor-unavailable'&&transportAudit.TRANSPORT_MONITOR_REASONS.includes(m.ruleId)?{reason:m.ruleId}:{}),...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:transportEvent?null:m.decision??(block?'block':null), content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null, ...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})} }; @@ -29,16 +30,16 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\ const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n'); const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3); export function toCEF(event,installationId='safetynet') { - const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',alert=event.kind==='sigma.alert'; + const m=event.metadata,transport=transportAudit.canonicalTransportHostname(m.host)?transportAudit.transportFactsForEvent(event.kind,event.metadata.transport):null,transportEvent=transportAudit.TRANSPORT_KINDS.includes(event.kind),interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',alert=event.kind==='sigma.alert'; // suid identifies the verified agent capability principal, not an employee account. // suser, when present, remains the independently supplied human actor. - const fields={...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, + const fields={...(transport?{cs2Label:'TransportProtocol',cs2:transport.protocol,cs3Label:'TransportPhase',cs3:transport.phase,cs6Label:'TransportRealm',cs6:transport.realm,requestContext:transport.sessionId,cs1Label:'TransportId',cs1:transport.transportId,flexNumber2Label:'ConnectionEstablished',flexNumber2:transport.connectionEstablished?1:0}:{}),...(event.kind==='transport.monitor-unavailable'&&transportAudit.TRANSPORT_MONITOR_REASONS.includes(m.ruleId)?{reason:m.ruleId}:{}),...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind, ...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}), - ...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}), + ...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host&&(!transportEvent||transport)?{dhost:m.host}:{}), ...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}), ...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}), ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}), ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}), ...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})}; - return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' '); + return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):!transportEvent&&blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' '); } diff --git a/packages/core/transport-facts.cjs b/packages/core/transport-facts.cjs new file mode 100644 index 0000000..5d99018 --- /dev/null +++ b/packages/core/transport-facts.cjs @@ -0,0 +1,12 @@ +'use strict'; +const {z}=require('zod'); +const TransportFactsSchema=z.object({transportId:z.string().uuid(),protocol:z.literal('webtransport'),phase:z.enum(['created','closed','established']),realm:z.enum(['window','worker','iframe']),sessionId:z.string().uuid(),connectionEstablished:z.boolean()}).strict().refine(f=>f.phase==='closed'||f.connectionEstablished===(f.phase==='established')); +const TRANSPORT_MONITOR_REASONS=Object.freeze(['transport-observer-detached','transport-observer-command-timeout','transport-observer-overflow','transport-observer-audit-failed','transport-observer-unavailable','unexpected-webtransport-established']); +const TRANSPORT_KINDS=Object.freeze(['transport.created','transport.closed','transport.established','transport.monitor-unavailable']); +function canonicalTransportHostname(host){ + if(typeof host!=='string'||!host.length||host.length>253)return false; + try{const url=new URL(`https://${host}`);return url.hostname===host&&url.host===host&&url.username===''&&url.password===''&&url.port===''&&url.pathname==='/'&&url.search===''&&url.hash==='';}catch{return false;} +} +function transportFacts(value){return TransportFactsSchema.parse(value);} +function transportFactsForEvent(kind,value){const parsed=TransportFactsSchema.safeParse(value);return parsed.success&&kind===`transport.${parsed.data.phase}`?parsed.data:null;} +module.exports={canonicalTransportHostname,TransportFactsSchema,transportFacts,transportFactsForEvent,TRANSPORT_MONITOR_REASONS,TRANSPORT_KINDS}; diff --git a/scripts/fullmain-wt-native.cjs b/scripts/fullmain-wt-native.cjs index f19cac6..46e39e3 100644 --- a/scripts/fullmain-wt-native.cjs +++ b/scripts/fullmain-wt-native.cjs @@ -6,9 +6,9 @@ if(mode==='managed'){const partition=session.fromPartition.bind(session),seenSes /* global WebTransport */ async function attemptWT(url,hash){const result={api:typeof WebTransport==='function',connected:false,datagramWritten:false,streamWritten:false,errorClass:null};let transport;try{transport=new WebTransport(url,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('timeout')),5000))]);result.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'),datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();result.datagramWritten=true;const stream=await transport.createUnidirectionalStream(),writer=stream.getWriter();await writer.write(bytes);await writer.close();result.streamWritten=true;await new Promise(r=>setTimeout(r,350));}catch(error){result.errorClass=['Error','TypeError','WebTransportError','NetworkError'].includes(error.name)?error.name:'OtherError';}finally{transport?.close();}return result;} const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Full main WebTransport fixture timeout');} -async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};onmessage=async e=>{postMessage(await attempt(e.data.url,e.data.hash));close();};`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};worker.postMessage({url:${JSON.stringify(target+'/worker')},hash:${JSON.stringify(hashBytes)}});})`);return {window,worker};} +async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};attempt(${JSON.stringify(target+'/worker')},${JSON.stringify(hashBytes)}).then(async result=>{await new Promise(r=>setTimeout(r,200));postMessage(result);close();});`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};})`);return {window,worker,workerExecution:'first-statement'};} app.whenReady().then(async()=>{let page,win,stage='startup';try{const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;if(mode==='baseline'){session.defaultSession.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});let callbacks=0;session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{callbacks++;callback({cancel:true});});win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});page=win.webContents;await win.loadURL(origin+'/workspace');const realms=await attemptRealms(page);assert.equal(callbacks,0);for(const realm of [realms.window,realms.worker])assert.ok(realm.connected&&realm.datagramWritten&&realm.streamWritten);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualElectron:true,mode,realms,webRequestCallbacks:callbacks,electron:process.versions.electron,chromium:process.versions.chrome},null,2));win.destroy();app.exit(0);return;} - win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay'); + win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,sessionId:(await ui('state')).activeTabId,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay'); const change=async proxy=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.proxy=proxy;const proposal=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposal.status,202);const review=await proposal.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()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');};stage='reviewed direct upstream policy';await change({mode:'direct'});await test('reviewed DIRECT upstream retains relay');stage='reviewed PAC direct upstream policy';await change({mode:'pac_script',pacUrl:origin+'/direct.pac'});await test('reviewed PAC DIRECT upstream retains relay'); - stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),unsupportedTransportAuditGap:'Chromium WebTransport bypasses Electron webRequest; receiver absence is native containment evidence, not an attributed per-attempt DLP audit event.',diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Full enrolled main WebTransport protection passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0); + stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await until(async()=>{const response=await fetch(base+'/api/admin/overview',{headers});assert.equal(response.status,200);const overview=await response.json();const rows=overview.events.filter(e=>e.kind.startsWith('transport.'));assert.ok(!rows.some(e=>e.kind==='transport.established'||e.kind==='transport.monitor-unavailable'));return rows.filter(e=>e.kind==='transport.created').length===6&&rows.filter(e=>e.kind==='transport.closed').length===6;});await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const transportEvents=events.filter(e=>e.event.action.startsWith('transport.'));assert.equal(transportEvents.filter(e=>e.event.action==='transport.created').length,6);assert.equal(transportEvents.filter(e=>e.event.action==='transport.closed').length,6);assert.equal(transportEvents.length,12);const created=new Map(),transportTraces=[];for(const e of transportEvents){const t=e.safetynet.transport;assert.ok(t);assert.deepEqual(Object.keys(t).sort(),['connection_established','phase','protocol','realm','session_id','transport_id']);assert.equal(t.protocol,'webtransport');assert.equal(t.connection_established,false);assert.deepEqual(e.event.type,['info']);assert.equal(e.event.outcome,'unknown');assert.equal(e.destination.domain,'127.0.0.1');assert.ok(['window','worker'].includes(t.realm));assert.equal(t.session_id,phases[0].sessionId);assert.ok(!JSON.stringify(e).includes(target));assert.ok(!JSON.stringify(e).includes(process.env.SAFETYNET_WT_HASH));assert.ok(!JSON.stringify(e).includes('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'));assert.ok(!/requestId|certificate|serverCertificateHashes|stackTrace|query/.test(JSON.stringify(e)));if(t.phase==='created'){assert.ok(!created.has(t.transport_id));created.set(t.transport_id,e);}else{const start=created.get(t.transport_id);assert.ok(start);assert.equal(start.safetynet.transport.realm,t.realm);assert.equal(start.safetynet.transport.session_id,t.session_id);assert.equal(start.safetynet.policy_version,e.safetynet.policy_version);}assert.ok(e.trace?.id);const exported=await(await fetch('http://127.0.0.1:4318/api/trace/'+e.trace.id)).json();assert.ok(!JSON.stringify(exported).includes(target));assert.ok(!JSON.stringify(exported).includes(process.env.SAFETYNET_WT_HASH));assert.ok(!JSON.stringify(exported).includes('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'));assert.ok(exported.spans.some(s=>s.name==='browser.transport'&&s.operation==='transport.'+t.phase));const byId=new Map(exported.spans.map(s=>[s.spanId,s]));for(const stage of exported.spans.filter(s=>s.name==='browser.security'&&s.operation?.startsWith('audit.'))){let current=stage,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name==='browser.transport'){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Transport audit stage parent missing');}transportTraces.push({phase:t.phase,realm:t.realm,transportId:t.transport_id,traceId:e.trace.id,stages:exported.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))});}for(const phase of phases){for(const realm of ['window','worker'])for(const eventPhase of ['created','closed'])assert.equal(transportEvents.filter(e=>e.safetynet.policy_version===phase.policyVersion&&e.safetynet.transport.realm===realm&&e.safetynet.transport.phase===eventPhase).length,1);}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),nativeTransportAudit:{created:6,closed:6,established:0,firstStatementWorker:true,events:transportEvents,transportTraces,coverage:'Actual native Window and dedicated Worker lifecycles in these three policy phases; no claim for every realm or future transport implementation.'},diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Full enrolled main WebTransport protection passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0); }catch(error){fs.writeFileSync(path.resolve('artifacts/fullmain-wt-native-error.json'),JSON.stringify({mode,stage,name:error.name},null,2));console.error('Full main WebTransport failed at '+stage+': '+error.name);if(mode==='managed'&&page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}}); diff --git a/scripts/fullmain-wt-proof.mjs b/scripts/fullmain-wt-proof.mjs index cb5d22a..705f277 100644 --- a/scripts/fullmain-wt-proof.mjs +++ b/scripts/fullmain-wt-proof.mjs @@ -11,11 +11,11 @@ import {createApp} from '../apps/server/app.mjs'; import {openStore,digest} from '../apps/server/store.mjs'; import {startTelemetry} from '../apps/server/telemetry.mjs'; const collector='http://127.0.0.1:4318';assert.equal((await fetch(collector+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(),root=mkdtempSync(join(tmpdir(),'safetynet-fullmain-wt-'));mkdirSync('artifacts',{recursive:true});assert.ok(resolve(root).startsWith(resolve(tmpdir())+sep)&&root.split(/[\\/]/).at(-1).startsWith('safetynet-fullmain-wt-')); -const files=['apps/browser/main.cjs','apps/browser/startup-security.cjs','apps/browser/local-dlp-relay.cjs','apps/browser/diagnostics.cjs','packages/core/policy.mjs','package.json'],sourceHashFacts=Object.fromEntries(files.map(file=>[file,createHash('sha256').update(readFileSync(file)).digest('hex')]));let python,page,management,store,child,stage='receiver startup';const ordinary={posts:0,pacRequests:0}; +const files=['apps/browser/main.cjs','apps/browser/native-transport-observer.cjs','apps/browser/page-clipboard-guard.cjs','packages/core/transport-facts.cjs','packages/core/siem.mjs','apps/browser/startup-security.cjs','apps/browser/local-dlp-relay.cjs','apps/browser/diagnostics.cjs','packages/core/policy.mjs','package.json'],sourceHashFacts=Object.fromEntries(files.map(file=>[file,createHash('sha256').update(readFileSync(file)).digest('hex')]));let python,page,management,store,child,stage='receiver startup';const ordinary={posts:0,pacRequests:0}; try{python=spawn('python',['scripts/webtransport-receiver.py',root],{env:{...process.env,PYTHONPATH:resolve('artifacts/webtransport-python')},windowsHide:true,stdio:['ignore','pipe','pipe']});python.stderr.on('data',()=>{});const metadata=await new Promise((accept,reject)=>{let text='';const timer=setTimeout(()=>reject(Error('Pinned QUIC receiver timeout')),15000);python.stdout.on('data',chunk=>{text+=chunk;try{const value=JSON.parse(text.split('\n')[0]);clearTimeout(timer);accept(value);}catch{}});python.once('error',()=>reject(Error('Pinned QUIC receiver startup refused')));python.once('exit',()=>{clearTimeout(timer);reject(Error('Pinned QUIC receiver exited'));});});const stats=`http://127.0.0.1:${metadata.statsPort}/facts`,target=`https://127.0.0.1:${metadata.quicPort}/webtransport`; page=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/direct.pac'){ordinary.pacRequests++;res.setHeader('content-type','application/x-ns-proxy-autoconfig');res.end('function FindProxyForURL(url,host) { return "DIRECT"; }');return;}if(req.method==='POST'){ordinary.posts++;req.resume();res.end('accepted');return;}res.setHeader('content-type','text/html');res.end('