Stabilize diagnostic export and preserve authorized policy continuity

This commit is contained in:
Karl committed 2026-10-14 01:18:23 +02:00
1 parent 0a5df7398e
commit 09bf616d98
18 files changed
+364 -57

No files matched your search

+5
View File
@@ -21,6 +21,11 @@ tested WebTransport lifecycle attempts without exposing raw browser debugging
APIs. Its observation channel preserves clipboard protection; unsupported APIs. Its observation channel preserves clipboard protection; unsupported
transport payloads remain blocked by the native relay boundary. transport payloads remain blocked by the native relay boundary.
[Bounded diagnostics](docs/diagnostics-backpressure.md) preserve authorized
exports through policy-version updates and cancel them when permissions change.
Current native clipboard, upload, restart, local Gemma and transport fixtures
pass with zero lifetime export failures; historical failures remain documented.
S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged. 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 ## A look inside
+24 -14
View File
@@ -51,14 +51,24 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
// spans, and attach it only to a currently recording browser operation. // spans, and attach it only to a currently recording browser operation.
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return; function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs})); 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);} 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:{...(parent.context||current.context),...attributes({component:'inspector',status:item.status,durationMs:item.duration})}},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
}catch{/* Diagnostics must never change inspection results. */}} }catch{/* Diagnostics must never change inspection results. */}}
function retire(current){if(!current)return;current.active=false;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 retire(current){if(!current)return;current.active=false;current.retiredAt=Date.now();for(const cancel of current.cancelExports||[])cancel();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 failureReason(error,attempt){const code=error?.code;if(code==='SAFETYNET_DIAGNOSTICS_QUEUE_OVERFLOW')return 'queue-overflow';if(code==='SAFETYNET_DIAGNOSTICS_QUEUE_EXPIRED')return 'queue-expired';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&&current.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 gatedExporter(exporter,current,signal){
// Each signal owns one native export. Pending records are bounded independently
// of the SDK processor; expiry uses original timestamps, never enqueue refresh.
const queue=[];let running=null,queuedRecords=0,cooling=false;
const fixedError=code=>Object.assign(Error('Diagnostics export failed'),{code});
const complete=(job,result)=>{if(job.finished)return;job.finished=true;clearTimeout(job.timer);if(current.attempts[signal]===job.attempt)delete current.attempts[signal];if(result.code!==0){current.failedExports++;lifetimeFailedExports++;const reason=failureReason(result.error,job.attempt);failureReasons[reason]=(failureReasons[reason]||0)+1;failureFacts.push({signal,generation:current.ordinal,reason,durationMs:Math.max(0,Date.now()-job.attempt.startedAt),queueAgeMs:Math.max(0,(job.dispatchedAt||Date.now())-job.attempt.startedAt),activeAtCompletion:current.active,retirementOverlapped:!!current.retiredAt&&current.retiredAt>=job.attempt.startedAt,cancelSignalled:job.attempt.cancelSignalled,httpStatus:job.attempt.httpStatus});if(failureFacts.length>32)failureFacts.shift();}try{job.callback(result);}finally{if(running===job){running=null;cooling=true;setImmediate(()=>{cooling=false;drain();});}else setImmediate(drain);}};
const drain=()=>{if(running||cooling||!current.active)return;const job=queue.shift();if(!job)return;queuedRecords-=job.records.length;clearTimeout(job.timer);if(Date.now()>=job.expiresAt){complete(job,{code:1,error:fixedError('SAFETYNET_DIAGNOSTICS_QUEUE_EXPIRED')});return;}running=job;job.dispatchedAt=Date.now();current.attempts[signal]=job.attempt;try{exporter.export(job.records,result=>complete(job,result));}catch(error){complete(job,{code:1,error});}};
current.cancelExports.add(()=>{for(const job of queue.splice(0)){queuedRecords-=job.records.length;job.attempt.cancelSignalled=true;complete(job,{code:1,error:fixedError('SAFETYNET_DIAGNOSTICS_CANCELLED')});}if(running){const job=running;job.attempt.cancelSignalled=true;complete(job,{code:1,error:fixedError('SAFETYNET_DIAGNOSTICS_CANCELLED')});}});
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 expiresAt=fresh.reduce((limit,record)=>{const stamp=record.endTime||record.hrTime||record.hrTimeObserved;return Math.min(limit,stamp[0]*1000+stamp[1]/1e6+current.config.maxRecordAgeMs);},now+current.config.maxRecordAgeMs);const job={records:fresh,callback,expiresAt,attempt:{startedAt:now,httpStatus:null,nativeCode:null,cancelSignalled:false},finished:false};if(queuedRecords+fresh.length>current.config.maxQueueSize){complete(job,{code:1,error:fixedError('SAFETYNET_DIAGNOSTICS_QUEUE_OVERFLOW')});return;}queue.push(job);queuedRecords+=fresh.length;job.timer=setTimeout(()=>{const index=queue.indexOf(job);if(index<0)return;queue.splice(index,1);queuedRecords-=job.records.length;complete(job,{code:1,error:fixedError('SAFETYNET_DIAGNOSTICS_QUEUE_EXPIRED')});},Math.max(1,Math.ceil(expiresAt-now)));job.timer.unref?.();drain();},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};
}
function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false; function canContinue(input={},context={}){if(closed||!generation?.active)return false;const config={...defaults,...input};if(!valid(config)||JSON.stringify(config)!==JSON.stringify(generation.config))return false;const identity=value=>Object.fromEntries(Object.entries(attributes(value)).filter(([key])=>key!=='safetynet.policy.version').sort(([a],[b])=>a.localeCompare(b)));return JSON.stringify(identity(context))===JSON.stringify(Object.fromEntries(Object.entries(generation.context).filter(([key])=>key!=='safetynet.policy.version').sort(([a],[b])=>a.localeCompare(b))));}
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),requests:new Map(),attempts:Object.create(null),ordinal:++generationOrdinal,failedExports:0}; 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;if(canContinue(input,context)){generation.context=Object.freeze(attributes(context));return true;}const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
let current;try{current={active:true,config,context:Object.freeze(attributes(context)),agents:new Set(),cancelExports: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 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 agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
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 base=config.endpoint.replace(/\/$/,'');const exporterConfig=(path,signal)=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:protocol=>agentFactory(protocol,signal)});
@@ -70,16 +80,16 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
}catch{generation=null;retire(current);return false;} }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; 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;
current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input)},...(parent?.generation===current?{context:trace.setSpan(ROOT_CONTEXT,parent.span)}:{})}); current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...(parent?.generation===current?parent.context||current.context:current.context),...attributes(input)},...(parent?.generation===current?{context:trace.setSpan(ROOT_CONTEXT,parent.span)}:{})});
}catch{} }catch{}
} }
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();} async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan,operationContext;try{const parent=storage.getStore();operationContext=parent?.generation===current?parent.context||current.context:current.context;const context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...operationContext,...attributes(input)}},context);}catch{return fn();}
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}} activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan,context:operationContext},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...operationContext,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
} }
// Synchronous persistence must retain its native return/throw contract. Never // Synchronous persistence must retain its native return/throw contract. Never
// wrap fsync or DPAPI in a promise merely to add diagnostic timing. // wrap fsync or DPAPI in a promise merely to add diagnostic timing.
function spanSync(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();} function spanSync(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan,operationContext;try{const parent=storage.getStore();operationContext=parent?.generation===current?parent.context||current.context:current.context;const context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...operationContext,...attributes(input)}},context);}catch{return fn();}
activeSpans++;const started=performance.now();let status='ok';try{const result=storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}} activeSpans++;const started=performance.now();let status='ok';try{const result=storage.run({generation:current,span:nativeSpan,context:operationContext},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...operationContext,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
} }
// Durable events may be delivered by a flusher started under another event's // Durable events may be delivered by a flusher started under another event's
// async context. Adopt only data-property IDs; never inherit that unrelated // async context. Adopt only data-property IDs; never inherit that unrelated
@@ -93,14 +103,14 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
if(typeof traceId==='string'&&/^[a-f0-9]{32}$/.test(traceId)&&traceId!=='0'.repeat(32)&&typeof spanId==='string'&&/^[a-f0-9]{16}$/.test(spanId)&&spanId!=='0'.repeat(16)){ if(typeof traceId==='string'&&/^[a-f0-9]{32}$/.test(traceId)&&traceId!=='0'.repeat(32)&&typeof spanId==='string'&&/^[a-f0-9]{16}$/.test(spanId)&&spanId!=='0'.repeat(16)){
const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate; const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;
const sampled=randomInt(1000000)<Math.floor(rate*1000000); const sampled=randomInt(1000000)<Math.floor(rate*1000000);
parent={generation:current,span:trace.wrapSpanContext({traceId,spanId,traceFlags:sampled?TraceFlags.SAMPLED:TraceFlags.NONE,isRemote:false})}; parent={generation:current,context:current.context,span:trace.wrapSpanContext({traceId,spanId,traceFlags:sampled?TraceFlags.SAMPLED:TraceFlags.NONE,isRemote:false})};
} }
} }
}catch{/* Invalid diagnostic metadata cannot affect protected delivery. */} }catch{/* Invalid diagnostic metadata cannot affect protected delivery. */}
return storage.run(parent,fn); return storage.run(parent,fn);
} }
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);} function flush(){const current=generation;if(!current?.active)return Promise.resolve();if(current.flushPromise)return current.flushPromise;const pending=Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);current.flushPromise=pending;pending.finally(()=>{if(current.flushPromise===pending)current.flushPromise=null;});return pending;}
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);} async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
return {configure,span,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}))})}; return {configure,canContinue,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}; module.exports={createDiagnostics,attributes,endpointAllowed};
+5 -1
View File
@@ -240,7 +240,11 @@ async function applyPolicyImpl(envelope){
// Same versions must describe identical policy; reject equivocation. // Same versions must describe identical policy; reject equivocation.
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return; if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
} }
configureDiagnostics({enabled:false});quarantine('Applying company policy…'); // The signed policy and bookmark assignment have been verified above. Keep
// an unchanged diagnostics authorization alive across a policy-only update;
// changed/disabled telemetry or expired authority still cancels immediately.
const continueDiagnostics=policy&&policy.version>0&&Date.parse(policy.expiresAt)>Date.now()&&diagnostics.canContinue?.(next.diagnostics,diagnosticContext({policyVersion:next.version}))===true;
if(!continueDiagnostics)configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy; const proxy=next.proxy;
const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})}; const settings={mode:proxy.mode,...(proxy.mode==='fixed_servers'?{proxyRules:proxy.rules,proxyBypassRules:proxy.bypass}:{}),...(proxy.mode==='pac_script'?{pacScript:proxy.pacUrl}:{})};
localRelay.invalidate();await browserSession.closeAllConnections(); localRelay.invalidate();await browserSession.closeAllConnections();
+86
View File
@@ -0,0 +1,86 @@
# Bounded diagnostics and policy continuity
Browser diagnostics are optional and centrally authorized. They never replace
the protected durable security audit. This change keeps inspection, release
checks, audit encryption and `fsync` unchanged.
## Browser exports
Each signal has one active SDK export and a FIFO bounded by the signed
`maxQueueSize` in waiting records, plus its active batch. Queue expiry uses the
original record timestamps; waiting does not refresh authorization or age.
Overflow, expiry, timeout and cancellation remain actual counted failures.
The process retains lifetime totals, fixed reason counts and at most 32 cloned
failure facts, including queue age. Error text, bodies, endpoints and credentials
are absent. Completed callbacks cannot count twice.
Flush calls share one promise per generation. Dispatch waits for the next event
loop turn after completion because the SDK frees its internal concurrency slot
after invoking the callback. Real successful HTTP drain tests cover that race,
including exports arriving during the callback-to-settlement interval.
An already verified, unexpired policy can keep diagnostics active through a
publication when the full exporter configuration and device context remain
identical, except for policy version. Policy and bookmark signatures and device
assignment are checked first. Operation contexts are immutable snapshots:
existing spans and logs keep their original version; new root operations use
the new version. Explicit operation metadata still overrides defaults.
Changed endpoint, mode, sampling, limits, identity or disabled diagnostics still
cancel active requests and waiting callbacks immediately. Expired previous
authority cannot use continuity. Cancellation remains visible in lifetime
failure accounting; it is not silently relabeled as successful delivery.
## Protected local development collector
The loopback receiver bounds unfinished OTLP requests to eight and their shared
body buffers to 8 MiB, with a 2 MiB per-body limit. Idle incomplete requests time
out after ten seconds. Overload returns fixed503; oversized bodies return413.
Aborts release reservations and remove waiting work. Commit work is serialized
without changing the encryption, path guards, rotation or synchronous `fsync`.
HTTP200 still follows actual encrypted persistence; storage failure returns507.
Admission summaries expose counts, bytes, queue wait and commit duration. Native
event-loop delay is metadata only. This is bounded admission and observability,
not a claim that synchronous disk work has been moved into a worker.
Startup verifies an existing exact private directory ACL without resetting it:
current user owns it, inheritance is disabled, and its sole explicit rule grants
that user FullControl with file/directory inheritance. Unsafe ACLs require a
successful protection and verification step or prevent startup. This avoids a
redundant owner-setting operation that failed under a non-elevated token.
The existing DPAPI key and encrypted archives remain intact across restart.
This developer receiver is not a production SIEM or remote collector.
## Current Windows evidence
The final frozen main/diagnostics source passed:
- 784 source tests, zero failures or skips, and ESLint with zero warnings.
- Actual native clipboard/upload enforcement:17 checks and five additional
checks after a real process restart;13 public requests, zero confidential
deliveries and two exactly received public files.
- Real local Gemma: all four semantic receiver checks, with confidential
paraphrases blocked and no external model inspection.
- Window and first-statement dedicated Worker WebTransport attempts through
three signed policy phases: six Created and six Closed security records,
zero managed packets/bytes, and three successful public HTTPS submissions.
- Zero current and lifetime export failures in all those browser processes.
Actual semantic file trace `e5ebf887a1ad8d90f065739a86cd2261` shows a9.04s
request containing several independently inspected representations. Individual
local stages took1.69–1.93s; semantic stages0.78–1.03s, with audit commit12–16ms.
These are measured profiling targets, not permission to remove required checks.
Collector checkpoint:578 commits, zero commit failures/rejections, max queue
wait14.87ms and max commit42.18ms, with no active or waiting request at inspection.
Ignored evidence: `artifacts/diagnostics-continuity-final-suite.log`,
`context-reuse-{public,mvp,semantic,wt}-native.log`, the corresponding native
proof JSON files and `diagnostics-continuity-collector-checkpoint.json`.
Use `node --test --test-concurrency=2 tests/*.test.mjs` for the full Windows suite.
Historical failed runs remain retained. Their earlier three2s export timeouts
cannot be causally explained retroactively by these later successful runs.
The separately measured SDK drain race and policy-retirement losses have direct
regression coverage. The native WebRTC engine crash remains a separate open
Windows MVP gate; see [the current scope](windows-dlp-mvp.md).
+14 -5
View File
@@ -84,6 +84,15 @@ that the whole application is clean, fully optimized or independently audited.
## Latest reproducible checks ## Latest reproducible checks
The latest final-source Windows acceptance supersedes the historical failed
runs below:784 tests, lint, packaged-artifact verification, actual clipboard,
upload, process restart, local Gemma and audited Window/Worker transport tests
pass. All native browser lifetimes have zero export failures. Bounded exporter
FIFO, an SDK completion barrier, single-flight flush, verified policy continuity,
collector admission and idempotent private ACL startup are implemented.
See [current evidence and limits](diagnostics-backpressure.md). The separate
WebRTC engine crash remains reproducible in the official44.7.1 candidate too.
A subsequent full run after the Established-alarm regression added a test A subsequent full run after the Established-alarm regression added a test
passed 764 of765 and exposed a real gateway cancellation race: the policy passed 764 of765 and exposed a real gateway cancellation race: the policy
monitor aborted the provider but discarded its fixed409 error, producing500. monitor aborted the provider but discarded its fixed409 error, producing500.
@@ -127,8 +136,8 @@ concurrency failure. A bounded export queue, single-flight flush and collector
commit-latency measurements are the next narrowly scoped diagnostics work; commit-latency measurements are the next narrowly scoped diagnostics work;
mandatory inspection and audit durability must remain intact. mandatory inspection and audit durability must remain intact.
- Full source suite: 771 tests passed with `--test-concurrency=2`, no failures - Full source suite: 784 tests passed with `--test-concurrency=2`, no failures
or skips; see `artifacts/transport-owner-final-failure-ledger-suite.log`. An earlier or skips; see `artifacts/diagnostics-continuity-final-suite.log`. An earlier
higher-concurrency run failed one evidence-key ACL check; its cause remains higher-concurrency run failed one evidence-key ACL check; its cause remains
unconfirmed and its log is retained. All 18 evidence tests passed separately. 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 No ACL protection or timeout was weakened. `npm run lint` passed with zero
@@ -143,9 +152,9 @@ mandatory inspection and audit durability must remain intact.
literal and semantic payloads. Restart reused the protected device profile. literal and semantic payloads. Restart reused the protected device profile.
These are separate from packaged-artifact acceptance. The final literal and These are separate from packaged-artifact acceptance. The final literal and
actual-restart fixtures pass17+5 checks with zero lifetime exports failed. actual-restart fixtures pass17+5 checks with zero lifetime exports failed.
The latest semantic receiver/model checks pass, but diagnostics acceptance The latest semantic receiver/model and diagnostics checks pass with zero
fails on three measured2s timeouts and one concurrency rejection. These are lifetime failures. Earlier three measured2s timeouts and one concurrency
retained as real failures, not cancelled exports or successful delivery. rejection remain retained as real failures, with no retrospective cause claim.
- The protected local OpenTelemetry receiver reported zero rejected writes. - The protected local OpenTelemetry receiver reported zero rejected writes.
Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request, Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request,
browser.inspect.local, inspector.inspect, semantic load/inference and durable browser.inspect.local, inspector.inspect, semantic load/inference and durable
+19
View File
@@ -4,6 +4,25 @@ Status: source patch prepared and checked; **not compiled or runtime verified**.
Do not distribute this patch as a proven security fix until the acceptance gates Do not distribute this patch as a proven security fix until the acceptance gates
below pass against the resulting binary. below pass against the resulting binary.
## Latest stable candidate checked
The official [Electron44.7.1 release](https://github.com/electron/electron/releases/tag/v44.7.1)
uses Chromium152.0.7977.149. Its Windows x64 ZIP was downloaded into an isolated
ignored directory, checked against the official SHA256 manifest and extracted
with contained-path, symlink and size checks. ZIP SHA256:
`ace7ce1580d33e9fe7284823d7e23ba687263c448d85d8dc4b5daa9bba73f97a`.
Actual positive controls in fresh profiles transmitted synthetic RTC bytes and
a benign HTTPS submission from both initial about:blank and srcdoc frames.
The matching managed header cases still terminated their renderers with
missing `network.mojom.MdnsResponder`, bad IPC123 and exit3. The tagged binder
source also lacks `NoOpP2PSocketManager`. Therefore this available stable binary
does not resolve the gate. Project dependencies remain unchanged.
Ignored evidence: `artifacts/electron4471-binary-proof.json`. The first run's
about:blank positive timeout remains retained; a separate profile-isolated
paired run verified both controls without changing attempts or deadlines.
## Exact basis and provenance ## Exact basis and provenance
The adjacent patch targets Chromium `152.0.7977.130`, used by Electron `44.7.0`. The adjacent patch targets Chromium `152.0.7977.130`, used by Electron `44.7.0`.
+5 -3
View File
@@ -61,9 +61,11 @@ hashes and `artifacts/cdp-webtransport-native-proof.json`. The latter additional
checks real DevTools coexistence and verifies that observer disposal preserves checks real DevTools coexistence and verifies that observer disposal preserves
clipboard ownership. Earlier literal/restart and real local Gemma regressions clipboard ownership. Earlier literal/restart and real local Gemma regressions
passed on the same source hashes. The later final-source literal/restart test 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 also passes. Historical semantic debug runs recorded three export timeouts and
three export timeouts and one concurrency rejection. Those failures remain one concurrency rejection. The latest semantic and audited transport runs now
open and are separate from receiver containment. These scenarios do not prove every worker, iframe, pass with zero lifetime failures after bounded exports and policy continuity;
see [diagnostics evidence](diagnostics-backpressure.md). Historical failures
remain retained without retrospective causal claims. These scenarios do not prove every worker, iframe,
shared-worker attribution or future Chromium transport implementation. shared-worker attribution or future Chromium transport implementation.
## Measured limits ## Measured limits
+5 -3
View File
@@ -7,12 +7,14 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap.
## Aktuelle nächste Abnahmen ## Aktuelle nächste Abnahmen
**Aktualisierung des Windows-MVP:** 771 Tests bestehen mit begrenzter **Aktualisierung des Windows-MVP:** 784 Tests bestehen mit begrenzter
Parallelität. Der aktuelle Windows-Build besteht Paket-, Fuse- und Parallelität. Der aktuelle Windows-Build besteht Paket-, Fuse- und
Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests, Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests,
Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen
Gemma-DLP-Prüfungen bestehen, ihre Debug-Abnahme scheitert noch an drei Gemma-DLP-Prüfungen und ihre Debug-Abnahme bestehen nach begrenzten Exporten und
Export-Timeouts und einer Concurrency-Ablehnung. WebTransport-Versuche in Fenster und Dedicated Worker sind nun Policy-Kontinuität erneut mit null aktuellen/lebenslangen Exportfehlern.
Historische Fehler bleiben dokumentiert; siehe [Diagnostik](diagnostics-backpressure.md).
WebTransport-Versuche in Fenster und Dedicated Worker sind nun
als korrelierte Security-Events und OpenTelemetry-Traces nachgewiesen; siehe als korrelierte Security-Events und OpenTelemetry-Traces nachgewiesen; siehe
[Transport-Nachweise](native-transport-observability.md). Der bekannte native [Transport-Nachweise](native-transport-observability.md). Der bekannte native
WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist. WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist.
+14 -10
View File
@@ -55,20 +55,24 @@ Requests und zwei exakt übertragenen Dateien. Aktuelle und lebenslange
Exportfehler bleiben in diesen Prozessen0. Der frische öffentliche Diagnosetest Exportfehler bleiben in diesen Prozessen0. Der frische öffentliche Diagnosetest
mit vier geprüften Policy-Veröffentlichungen besteht ebenfalls. mit vier geprüften Policy-Veröffentlichungen besteht ebenfalls.
**Noch offen:** Die letzte echte lokale Gemma-Abnahme besteht alle vier **Debug-Abnahme bestanden:** Nach begrenzter Export-FIFO, gemeinsamem Flush,
DLP-/Empfängerprüfungen, scheitert aber an drei gemessenen2s-Export-Timeouts und SDK-Abschlussbarriere und signierter Policy-Kontinuität bestehen alle vier
einem `concurrency-limit`. Die neuen begrenzten Fehlerdaten belegen aktive echten lokalen Gemma-Prüfungen und die vollständige WebTransport-Abnahme erneut
Generation1 ohne Policy-Abbruch; sie beweisen noch nicht die Ursache der mit null aktuellen und lebenslangen Exportfehlern. Native Clipboard-/Upload-
Antwortverzögerung. Kein Zähler wurde gelöscht oder eine Erwartung gelockert. und echte Neustarttests bestehen ebenfalls mit17+5 Prüfungen. Der Collector
Für die Debug-Abnahme sind begrenzte Export-Warteschlangen, ein gemeinsamer meldet578 Commits, null Fehler/Ablehnungen und maximal14.87ms Queue-Wartezeit.
Flush und Latenz-/Eventloop-Nachweise zu prüfen. Ein weiterer geschützter Timeout-Grenzen, Fehlerzähler und Abnahmeerwartungen wurden nicht gelockert.
Transporttest wurde nach diesem Fehler nicht einfach wiederholt. Historische Timeout- und Cancellation-Fehler bleiben als Nachweise erhalten;
die früheren Timeout-Ursachen sind nicht rückwirkend bewiesen.
Details und Reproduktion: [begrenzte Diagnostik](diagnostics-backpressure.md).
**Noch offen:**
Der native WebRTC-Absturz bleibt ein separater offener Stabilitäts-Gate. Der native WebRTC-Absturz bleibt ein separater offener Stabilitäts-Gate.
Das MVP ist deshalb weiterhin **nicht abgeschlossen**. Das MVP ist deshalb weiterhin **nicht abgeschlossen**.
771 Tests bestehen im letzten vollständigen Lauf mit zwei parallelen Testdateien; 784 Tests bestehen im letzten vollständigen Lauf mit zwei parallelen Testdateien;
ESLint verlangt null Fehler und null Warnungen. Nachweis: ESLint verlangt null Fehler und null Warnungen. Nachweis:
`artifacts/transport-owner-final-failure-ledger-suite.log`. Ein späterer `artifacts/diagnostics-continuity-final-suite.log`. Ein späterer
Policy-Abbruch verlor ursprünglich seinen festen409-Fehler und lieferte500; Policy-Abbruch verlor ursprünglich seinen festen409-Fehler und lieferte500;
dieser echte Gateway-Fehler ist mit deterministischen Tests und exportierten dieser echte Gateway-Fehler ist mit deterministischen Tests und exportierten
Traces behoben. Der stärker parallelisierte Traces behoben. Der stärker parallelisierte
+32
View File
@@ -0,0 +1,32 @@
import {spawnSync} from 'node:child_process';
// Do not reapply ownership to an already private directory: Windows may require
// restore privileges for SetOwner even when the current account already owns it.
const command=`$ErrorActionPreference='Stop';
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;
function Test-PrivateAcl($acl){
if(-not $acl.AreAccessRulesProtected -or $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){return $false;}
$rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]));
if($rules.Count -ne 1){return $false;}
$rule=$rules[0];
return (-not $rule.IsInherited -and $rule.IdentityReference.Value -eq $sid.Value -and $rule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $rule.FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and $rule.InheritanceFlags -eq ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -and $rule.PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None);
}
$existing=Get-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY;
$changed=$false;
if(-not (Test-PrivateAcl $existing)){
$private=New-Object System.Security.AccessControl.DirectorySecurity;
$private.SetOwner($sid);$private.SetAccessRuleProtection($true,$false);
$private.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')));
Set-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY -AclObject $private;
$changed=$true;
}
$verified=Get-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY;
if(-not (Test-PrivateAcl $verified)){throw 'Collector directory protection verification failed';}
@{verified=$true;changed=$changed}|ConvertTo-Json -Compress;`;
export function protectCollectorDirectory(directory,{run=spawnSync}={}){
const result=run('powershell.exe',['-NoProfile','-NonInteractive','-Command',command],{windowsHide:true,timeout:10000,env:{SystemRoot:process.env.SystemRoot,SAFETYNET_COLLECTOR_DIRECTORY:directory},stdio:'pipe',encoding:'utf8'});
if(result.status!==0)throw Error('Collector directory protection failed');
let proof;try{proof=JSON.parse(result.stdout);}catch{throw Error('Collector directory protection verification failed');}
if(proof?.verified!==true||typeof proof.changed!=='boolean')throw Error('Collector directory protection verification failed');
return Object.freeze({verified:true,changed:proof.changed});
}
+17
View File
@@ -0,0 +1,17 @@
import {performance} from 'node:perf_hooks';
// Bounds unfinished HTTP bodies and committed-work admission together. This is
// diagnostic telemetry, separate from the durable security-event pipeline.
export function collectorAdmission({maxRequests=8,maxBufferedBytes=8*1024*1024,maxRequestBytes=2*1024*1024,clock=()=>performance.now(),defer=setImmediate}={}){
if([maxRequests,maxBufferedBytes,maxRequestBytes].some(value=>!Number.isSafeInteger(value)||value<1)||maxRequests>64||maxBufferedBytes>64*1024*1024||maxRequestBytes>2*1024*1024||typeof clock!=='function'||typeof defer!=='function')throw TypeError('Invalid collector admission limits');
let active=0,buffered=0,peakRequests=0,peakBufferedBytes=0,running=false;const queue=[];
const counters={requestLimitRejected:0,bufferLimitRejected:0,bodyLimitRejected:0,commits:0,commitFailures:0,lastQueueWaitMs:0,maxQueueWaitMs:0,lastCommitMs:0,maxCommitMs:0};
const duration=start=>Math.max(0,clock()-start);
function drain(){if(running||!queue.length)return;running=true;defer(async()=>{const job=queue.shift();if(!job){running=false;drain();return;}try{if(!job.live())throw Error('Collector request cancelled');const wait=duration(job.readyAt);counters.lastQueueWaitMs=wait;counters.maxQueueWaitMs=Math.max(counters.maxQueueWaitMs,wait);const start=clock();try{const result=await job.fn();counters.commits++;job.resolve(result);}catch(error){counters.commitFailures++;job.reject(error);}finally{const elapsed=duration(start);counters.lastCommitMs=elapsed;counters.maxCommitMs=Math.max(counters.maxCommitMs,elapsed);}}catch(error){job.reject(error);}finally{running=false;drain();}});}
function acquire(){if(active>=maxRequests){counters.requestLimitRejected++;return null;}active++;peakRequests=Math.max(peakRequests,active);let bytes=0,released=false,enqueued=false,queuedJob;
return {add(size){if(released||enqueued||!Number.isSafeInteger(size)||size<0)throw Error('Collector admission unavailable');if(bytes+size>maxRequestBytes){counters.bodyLimitRejected++;return 'body-limit';}if(buffered+size>maxBufferedBytes){counters.bufferLimitRejected++;return 'buffer-limit';}bytes+=size;buffered+=size;peakBufferedBytes=Math.max(peakBufferedBytes,buffered);return null;},
enqueue(fn){if(released||enqueued||typeof fn!=='function')return Promise.reject(Error('Collector admission unavailable'));enqueued=true;return new Promise((resolve,reject)=>{queuedJob={fn,resolve,reject,readyAt:clock(),live:()=>!released};queue.push(queuedJob);drain();});},
release(){if(released)return;released=true;active--;buffered-=bytes;bytes=0;if(queuedJob){const index=queue.indexOf(queuedJob);if(index>=0){queue.splice(index,1);queuedJob.reject(Error('Collector request cancelled'));}}}};
}
return {acquire,summary:()=>({maxRequests,maxBufferedBytes,maxRequestBytes,activeRequests:active,bufferedBytes:buffered,queuedCommits:queue.length,commitActive:running,peakRequests,peakBufferedBytes,...counters})};
}
+34 -15
View File
@@ -3,6 +3,9 @@ import {createServer} from 'node:http';
import {mkdirSync,existsSync,readFileSync,writeFileSync} from 'node:fs'; import {mkdirSync,existsSync,readFileSync,writeFileSync} from 'node:fs';
import {collectorStorage} from './debug-collector-storage.mjs'; import {collectorStorage} from './debug-collector-storage.mjs';
import {normalizeCollectorBatch} from './debug-collector-batch.mjs'; import {normalizeCollectorBatch} from './debug-collector-batch.mjs';
import {collectorAdmission} from './debug-collector-admission.mjs';
import {protectCollectorDirectory} from './debug-collector-acl.mjs';
import {monitorEventLoopDelay} from 'node:perf_hooks';
import {resolve} from 'node:path'; import {resolve} from 'node:path';
import {spawnSync} from 'node:child_process'; import {spawnSync} from 'node:child_process';
import {randomBytes,createCipheriv} from 'node:crypto'; import {randomBytes,createCipheriv} from 'node:crypto';
@@ -10,14 +13,16 @@ const port=Number(process.env.DEBUG_COLLECTOR_PORT||4318),directory=resolve(proc
if(process.platform!=='win32')throw Error('Local protected collector currently requires Windows DPAPI'); if(process.platform!=='win32')throw Error('Local protected collector currently requires Windows DPAPI');
mkdirSync(directory,{recursive:true}); mkdirSync(directory,{recursive:true});
const storage=collectorStorage(directory,{segmentBytes:Number(process.env.DEBUG_COLLECTOR_SEGMENT_BYTES||50*1024*1024),retiredSegments:Number(process.env.DEBUG_COLLECTOR_RETIRED_SEGMENTS||2)}); const storage=collectorStorage(directory,{segmentBytes:Number(process.env.DEBUG_COLLECTOR_SEGMENT_BYTES||50*1024*1024),retiredSegments:Number(process.env.DEBUG_COLLECTOR_RETIRED_SEGMENTS||2)});
const acl=spawnSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$a=New-Object System.Security.AccessControl.DirectorySecurity;$a.SetOwner($sid);$a.SetAccessRuleProtection($true,$false);$a.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')));Set-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY -AclObject $a"],{windowsHide:true,timeout:10000,env:{SystemRoot:process.env.SystemRoot,SAFETYNET_COLLECTOR_DIRECTORY:directory},stdio:'pipe'}); protectCollectorDirectory(directory);
if(acl.status!==0)throw Error('Collector directory protection failed');
function protect(operation,value){const result=spawnSync(resolve('.runtime/windows/python/python.exe'),[resolve('apps/inspector/dpapi.py'),operation],{input:value,encoding:'utf8',windowsHide:true,timeout:5000,env:{SystemRoot:process.env.SystemRoot,USERPROFILE:process.env.USERPROFILE,LOCALAPPDATA:process.env.LOCALAPPDATA,PYTHONDONTWRITEBYTECODE:'1'}});if(result.status!==0)throw Error('Collector DPAPI key protection failed');return result.stdout.trim();} function protect(operation,value){const result=spawnSync(resolve('.runtime/windows/python/python.exe'),[resolve('apps/inspector/dpapi.py'),operation],{input:value,encoding:'utf8',windowsHide:true,timeout:5000,env:{SystemRoot:process.env.SystemRoot,USERPROFILE:process.env.USERPROFILE,LOCALAPPDATA:process.env.LOCALAPPDATA,PYTHONDONTWRITEBYTECODE:'1'}});if(result.status!==0)throw Error('Collector DPAPI key protection failed');return result.stdout.trim();}
const keyFile=resolve(directory,'collector-key.dpapi'); const keyFile=resolve(directory,'collector-key.dpapi');
let key;if(existsSync(keyFile)){const value=protect('unprotect',readFileSync(keyFile,'utf8'));if(!value.startsWith('observability-v1:'))throw Error('Collector key binding rejected');key=Buffer.from(value.slice(17),'base64');}else{key=randomBytes(32);writeFileSync(keyFile,protect('protect','observability-v1:'+key.toString('base64')),{flag:'wx',mode:0o600});} let key;if(existsSync(keyFile)){const value=protect('unprotect',readFileSync(keyFile,'utf8'));if(!value.startsWith('observability-v1:'))throw Error('Collector key binding rejected');key=Buffer.from(value.slice(17),'base64');}else{key=randomBytes(32);writeFileSync(keyFile,protect('protect','observability-v1:'+key.toString('base64')),{flag:'wx',mode:0o600});}
if(key.length!==32)throw Error('Invalid collector key'); if(key.length!==32)throw Error('Invalid collector key');
const admission=collectorAdmission({maxRequests:Number(process.env.DEBUG_COLLECTOR_MAX_REQUESTS||8),maxBufferedBytes:Number(process.env.DEBUG_COLLECTOR_MAX_BUFFERED_BYTES||8*1024*1024)});
const eventLoop=monitorEventLoopDelay({resolution:20});eventLoop.enable();
const lag=()=>({meanMs:Number.isFinite(eventLoop.mean)?eventLoop.mean/1e6:0,maxMs:eventLoop.max/1e6,p99Ms:eventLoop.percentile(99)/1e6});
const counts={traces:0,logs:0,metrics:0},recent=[];let requests=0; const counts={traces:0,logs:0,metrics:0},recent=[];let requests=0;
function summary(){return {service:'safetynet-local-debug',protectedStorage:'Windows DPAPI + AES-256-GCM',requests,counts,...storage.summary(),retainedSpans:recent.length,slowest:recent.slice().sort((a,b)=>b.durationMs-a.durationMs).slice(0,15)};} function summary(){return {service:'safetynet-local-debug',protectedStorage:'Windows DPAPI + AES-256-GCM',requests,counts,admission:admission.summary(),eventLoopLag:lag(),...storage.summary(),retainedSpans:recent.length,slowest:recent.slice().sort((a,b)=>b.durationMs-a.durationMs).slice(0,15)};}
const server=createServer((req,res)=>{ const server=createServer((req,res)=>{
res.setHeader('cache-control','no-store');res.setHeader('x-content-type-options','nosniff'); res.setHeader('cache-control','no-store');res.setHeader('x-content-type-options','nosniff');
const origin=`http://127.0.0.1:${port}`; const origin=`http://127.0.0.1:${port}`;
@@ -25,17 +30,31 @@ const server=createServer((req,res)=>{
if(req.method==='GET'&&['/','/health','/api/summary'].includes(req.url)){res.setHeader('content-type','application/json');try{res.end(JSON.stringify(summary(),null,2));}catch{res.writeHead(503);res.end('{"error":"Protected collector storage unavailable"}');}return;} if(req.method==='GET'&&['/','/health','/api/summary'].includes(req.url)){res.setHeader('content-type','application/json');try{res.end(JSON.stringify(summary(),null,2));}catch{res.writeHead(503);res.end('{"error":"Protected collector storage unavailable"}');}return;}
if(req.method==='GET'&&/^\/api\/trace\/[a-f0-9]{32}$/.test(req.url)){res.setHeader('content-type','application/json');res.end(JSON.stringify({traceId:req.url.slice(11),spans:recent.filter(span=>span.traceId===req.url.slice(11))},null,2));return;} if(req.method==='GET'&&/^\/api\/trace\/[a-f0-9]{32}$/.test(req.url)){res.setHeader('content-type','application/json');res.end(JSON.stringify({traceId:req.url.slice(11),spans:recent.filter(span=>span.traceId===req.url.slice(11))},null,2));return;}
if(req.method!=='POST'||!['/v1/traces','/v1/logs','/v1/metrics'].includes(req.url)||req.headers['content-encoding']){res.writeHead(404);res.end();return;} if(req.method!=='POST'||!['/v1/traces','/v1/logs','/v1/metrics'].includes(req.url)||req.headers['content-encoding']){res.writeHead(404);res.end();return;}
const chunks=[];let size=0;req.on('data',chunk=>{size+=chunk.length;if(size>2*1024*1024)req.destroy();else chunks.push(chunk);}); const lease=admission.acquire();
req.on('end',()=>{try{ const rejectBusy=()=>{res.setHeader('connection','close');res.setHeader('retry-after','1');res.writeHead(503);res.end('{"error":"Collector busy"}');};
const payload=JSON.parse(Buffer.concat(chunks).toString('utf8')),signal=req.url.slice(4);if(!payload||typeof payload!=='object'||Array.isArray(payload))throw Error('Invalid OTLP'); if(!lease){rejectBusy();return;}
const normalized=normalizeCollectorBatch(signal,payload); const chunks=[];let finished=false;
const nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(Buffer.from('safetynet-debug-v1')); const release=()=>{lease.release();chunks.length=0;};
const plain=Buffer.from(JSON.stringify({signal,receivedAt:new Date().toISOString(),payload})),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0); const fail=(status,body)=>{if(finished)return;finished=true;release();res.setHeader('connection','close');res.writeHead(status);res.end(body);};
const line=JSON.stringify({v:1,nonce:nonce.toString('base64'),tag:cipher.getAuthTag().toString('base64'),ciphertext:encrypted.toString('base64')})+'\n'; req.once('aborted',()=>{finished=true;release();});req.once('error',()=>{finished=true;release();});
try{storage.append(line);}catch{res.writeHead(507);res.end('{"error":"Protected collector storage unavailable"}');return;}requests++; res.once('close',()=>{if(!finished){finished=true;release();}});
counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift(); req.setTimeout(10000,()=>fail(408,'{"error":"Collector request timed out"}'));
res.setHeader('content-type','application/json');res.end('{}'); req.on('data',chunk=>{if(finished)return;const rejected=lease.add(chunk.length);if(rejected){if(rejected==='body-limit')fail(413,'{"error":"Telemetry batch too large"}');else{finished=true;release();rejectBusy();}}else chunks.push(chunk);});
}catch{res.writeHead(400);res.end('{"error":"Invalid telemetry batch"}');}}); req.on('end',()=>{if(finished)return;req.setTimeout(0);
void lease.enqueue(()=>{if(finished)return;
try{
const payload=JSON.parse(Buffer.concat(chunks).toString('utf8')),signal=req.url.slice(4);if(!payload||typeof payload!=='object'||Array.isArray(payload))throw Error('Invalid OTLP');
const normalized=normalizeCollectorBatch(signal,payload);
const nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(Buffer.from('safetynet-debug-v1'));
const plain=Buffer.from(JSON.stringify({signal,receivedAt:new Date().toISOString(),payload})),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);
const line=JSON.stringify({v:1,nonce:nonce.toString('base64'),tag:cipher.getAuthTag().toString('base64'),ciphertext:encrypted.toString('base64')})+'\n';
// HTTP success remains after the guarded encrypted append and fsync.
try{storage.append(line);}catch{fail(507,'{"error":"Protected collector storage unavailable"}');throw Error('Collector commit failed');}
requests++;counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift();
finished=true;res.setHeader('content-type','application/json');res.end('{}');
}catch{fail(400,'{"error":"Invalid telemetry batch"}');throw Error('Collector batch failed');}
}).catch(()=>{if(!finished)fail(503,'{"error":"Collector busy"}');}).finally(release);
});
}); });
server.listen(port,'127.0.0.1',()=>console.log(`Local debug collector: http://127.0.0.1:${port}/api/summary`)); server.listen(port,'127.0.0.1',()=>console.log(`Local debug collector: http://127.0.0.1:${port}/api/summary`));
function stop(){server.closeAllConnections();server.close(()=>{key.fill(0);process.exit(0);});}process.on('SIGINT',stop);process.on('SIGTERM',stop); function stop(){eventLoop.disable();server.closeAllConnections();server.close(()=>{key.fill(0);process.exit(0);});}process.on('SIGINT',stop);process.on('SIGTERM',stop);
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test';import assert from 'node:assert/strict';
import {mkdtempSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';
import {protectCollectorDirectory} from '../scripts/debug-collector-acl.mjs';
test('ACL proof rejects process failures and unverified or malformed success without exposing errors',()=>{
for(const result of [{status:1,stdout:'{"verified":true,"changed":false}',stderr:'private path/account'},{status:0,stdout:'{"verified":false,"changed":false}'},{status:0,stdout:'{}'},{status:0,stdout:'private error'}])assert.throws(()=>protectCollectorDirectory('unused',{run:()=>result}),error=>error.message.startsWith('Collector directory protection')&&!error.message.includes('private'));
});
test('actual Windows exact private ACL is verified without being reapplied on restart',{skip:process.platform!=='win32'},t=>{
const directory=mkdtempSync(join(tmpdir(),'safetynet-collector-acl-'));t.after(()=>{assert.ok(resolve(directory).startsWith(resolve(tmpdir())));rmSync(directory,{recursive:true,force:true});});
const initial=protectCollectorDirectory(directory);assert.equal(initial.verified,true);
assert.deepEqual(protectCollectorDirectory(directory),{verified:true,changed:false});
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {collectorAdmission} from '../scripts/debug-collector-admission.mjs';
test('unfinished requests and accumulated body bytes have independent global bounds',()=>{
const gate=collectorAdmission({maxRequests:2,maxBufferedBytes:16,maxRequestBytes:12}),a=gate.acquire(),b=gate.acquire();assert.equal(gate.acquire(),null);
assert.equal(a.add(10),null);assert.equal(b.add(7),'buffer-limit');assert.equal(b.add(6),null);assert.equal(a.add(3),'body-limit');
assert.equal(gate.summary().activeRequests,2);assert.equal(gate.summary().bufferedBytes,16);a.release();a.release();assert.equal(gate.summary().bufferedBytes,6);b.release();
assert.equal(gate.summary().activeRequests,0);assert.equal(gate.summary().bufferedBytes,0);assert.equal(gate.summary().requestLimitRejected,1);assert.equal(gate.summary().bufferLimitRejected,1);assert.equal(gate.summary().bodyLimitRejected,1);
});
test('commit jobs serialize and acknowledge only after the held commit completes',async()=>{
const gate=collectorAdmission({maxRequests:2}),a=gate.acquire(),b=gate.acquire();let release;const held=new Promise(resolve=>{release=resolve;}),steps=[];
const first=a.enqueue(async()=>{steps.push('first-start');await held;steps.push('first-committed');return 'committed';});const second=b.enqueue(()=>{steps.push('second-committed');return 'second';});
await new Promise(resolve=>setImmediate(resolve));assert.deepEqual(steps,['first-start']);assert.equal(gate.summary().queuedCommits,1);release();assert.equal(await first,'committed');assert.equal(await second,'second');assert.deepEqual(steps,['first-start','first-committed','second-committed']);
a.release();b.release();const summary=gate.summary();assert.equal(summary.commits,2);assert.equal(summary.commitFailures,0);assert.ok(summary.maxQueueWaitMs>=0);assert.ok(summary.maxCommitMs>=0);
});
test('aborted queued requests cannot grow the waiting queue or execute cancelled work',async()=>{
const scheduled=[],gate=collectorAdmission({maxRequests:1,defer:fn=>scheduled.push(fn)});
for(let n=0;n<100;n++){const lease=gate.acquire();const rejected=assert.rejects(lease.enqueue(()=>assert.fail('Cancelled batch committed')));lease.release();await rejected;assert.equal(gate.summary().queuedCommits,0);}
for(const run of scheduled)await run();assert.equal(gate.summary().activeRequests,0);assert.equal(gate.summary().commits,0);
});
test('failed commits stay failed and record bounded numeric timings without raw errors',async()=>{
const gate=collectorAdmission(),lease=gate.acquire();await assert.rejects(lease.enqueue(()=>{throw Error('private-body');}));lease.release();const summary=gate.summary();assert.equal(summary.commits,0);assert.equal(summary.commitFailures,1);assert.equal(JSON.stringify(summary).includes('private-body'),false);
});
+12 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync,readdirSync,readFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {spawn} from 'node:child_process';import {createServer} from 'node:net'; import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync,readdirSync,readFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {spawn} from 'node:child_process';import {createServer} from 'node:net';import {request} from 'node:http';
test('actual Windows protected collector rotates encrypted exports and rejects oversized storage without acknowledgement',{skip:process.platform!=='win32'},async t=>{ test('actual Windows protected collector rotates encrypted exports and rejects oversized storage without acknowledgement',{skip:process.platform!=='win32'},async t=>{
const root=mkdtempSync(join(tmpdir(),'safetynet-collector-http-'));const probe=createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r)); const root=mkdtempSync(join(tmpdir(),'safetynet-collector-http-'));const probe=createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
const child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1'}}); let child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1',DEBUG_COLLECTOR_MAX_REQUESTS:'2',DEBUG_COLLECTOR_MAX_BUFFERED_BYTES:'4096'}});
let output='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);t.after(async()=>{if(child.exitCode===null){child.kill();await new Promise(r=>child.once('exit',r));}assert.ok(root.startsWith(join(tmpdir(),'safetynet-collector-http-')));rmSync(root,{recursive:true,force:true});}); let output='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);t.after(async()=>{if(child.exitCode===null){child.kill();await new Promise(r=>child.once('exit',r));}assert.ok(root.startsWith(join(tmpdir(),'safetynet-collector-http-')));rmSync(root,{recursive:true,force:true});});
const base=`http://127.0.0.1:${port}`;let health; const base=`http://127.0.0.1:${port}`;let health;
for(let i=0;i<80;i++){if(child.exitCode!==null)throw Error('Collector test process failed: '+output);try{health=await (await fetch(base+'/health')).json();break;}catch{await new Promise(r=>setTimeout(r,100));}} for(let i=0;i<80;i++){if(child.exitCode!==null)throw Error('Collector test process failed: '+output);try{health=await (await fetch(base+'/health')).json();break;}catch{await new Promise(r=>setTimeout(r,100));}}
@@ -9,6 +9,12 @@ test('actual Windows protected collector rotates encrypted exports and rejects o
for(let i=0;i<12;i++)assert.equal((await post({resourceLogs:[{scopeLogs:[{logRecords:[{body:{stringValue:marker}}]}]}]})).status,200); for(let i=0;i<12;i++)assert.equal((await post({resourceLogs:[{scopeLogs:[{logRecords:[{body:{stringValue:marker}}]}]}]})).status,200);
health=await (await fetch(base+'/api/summary')).json();assert.equal(health.requests,12);assert.equal(health.counts.logs,12);assert.ok(health.rotations>1);assert.ok(health.droppedSegments>0);assert.equal(health.retiredSegments,1);assert.ok(health.activeBytes+health.retiredBytes<=2048); health=await (await fetch(base+'/api/summary')).json();assert.equal(health.requests,12);assert.equal(health.counts.logs,12);assert.ok(health.rotations>1);assert.ok(health.droppedSegments>0);assert.equal(health.retiredSegments,1);assert.ok(health.activeBytes+health.retiredBytes<=2048);
for(const name of readdirSync(root).filter(n=>n.endsWith('.ndjson'))){const content=readFileSync(join(root,name),'utf8');assert.equal(content.includes(marker),false);for(const line of content.trim().split('\n')){const envelope=JSON.parse(line);assert.equal(envelope.v,1);assert.ok(envelope.nonce&&envelope.tag&&envelope.ciphertext);}} for(const name of readdirSync(root).filter(n=>n.endsWith('.ndjson'))){const content=readFileSync(join(root,name),'utf8');assert.equal(content.includes(marker),false);for(const line of content.trim().split('\n')){const envelope=JSON.parse(line);assert.equal(envelope.v,1);assert.ok(envelope.nonce&&envelope.tag&&envelope.ciphertext);}}
const waitAdmission=async check=>{for(let n=0;n<80;n++){const value=await (await fetch(base+'/health')).json();if(check(value.admission))return value;await new Promise(resolve=>setTimeout(resolve,10));}throw Error('Collector admission state timed out');};
const hold=()=>{let respond;const response=new Promise(resolve=>{respond=resolve;}),req=request(base+'/v1/logs',{method:'POST',headers:{'content-type':'application/json','transfer-encoding':'chunked'}},res=>{let body='';res.on('data',chunk=>body+=chunk);res.on('end',()=>respond({status:res.statusCode,body}));});req.on('error',()=>{});req.write(' ');return {req,response};};
const a=hold(),b=hold();await waitAdmission(value=>value.activeRequests===2);
const busy=await post({resourceLogs:[]});assert.equal(busy.status,503);assert.deepEqual(await busy.json(),{error:'Collector busy'});a.req.destroy();b.req.destroy();await waitAdmission(value=>value.activeRequests===0&&value.bufferedBytes===0);
const c=hold();c.req.write(' '.repeat(2999));await waitAdmission(value=>value.bufferedBytes===3000);const d=hold();d.req.write(' '.repeat(1099));const overflow=await d.response;assert.equal(overflow.status,503);assert.deepEqual(JSON.parse(overflow.body),{error:'Collector busy'});c.req.destroy();d.req.destroy();
health=await waitAdmission(value=>value.activeRequests===0&&value.bufferedBytes===0);assert.equal(health.requests,12);assert.equal(health.admission.requestLimitRejected,1);assert.equal(health.admission.bufferLimitRejected,1);assert.ok(health.admission.peakBufferedBytes<=4096);assert.ok(health.admission.maxQueueWaitMs>=0);assert.ok(health.admission.maxCommitMs>=0);assert.ok(health.eventLoopLag.maxMs>=0);
assert.equal((await post({large:'x'.repeat(2048)})).status,507);health=await (await fetch(base+'/health')).json();assert.equal(health.requests,12);assert.equal(health.rejectedWrites,1); assert.equal((await post({large:'x'.repeat(2048)})).status,507);health=await (await fetch(base+'/health')).json();assert.equal(health.requests,12);assert.equal(health.rejectedWrites,1);
const before=structuredClone(health);const tracePost=body=>fetch(base+'/v1/traces',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); const before=structuredClone(health);const tracePost=body=>fetch(base+'/v1/traces',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
const span={traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef',name:'synthetic.stage',startTimeUnixNano:'10',endTimeUnixNano:'11'}; const span={traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef',name:'synthetic.stage',startTimeUnixNano:'10',endTimeUnixNano:'11'};
@@ -16,4 +22,8 @@ test('actual Windows protected collector rotates encrypted exports and rejects o
assert.equal((await tracePost({resourceSpans:{}})).status,400);assert.equal((await post({resourceLogs:[{scopeLogs:{}}]})).status,400); assert.equal((await tracePost({resourceSpans:{}})).status,400);assert.equal((await post({resourceLogs:[{scopeLogs:{}}]})).status,400);
health=await (await fetch(base+'/health')).json();for(const field of ['requests','storedBytes','activeBytes','legacyBytes','retiredBytes','rotations','droppedSegments','droppedBytes','rejectedWrites','retainedSpans'])assert.equal(health[field],before[field]);assert.deepEqual(health.counts,before.counts); health=await (await fetch(base+'/health')).json();for(const field of ['requests','storedBytes','activeBytes','legacyBytes','retiredBytes','rotations','droppedSegments','droppedBytes','rejectedWrites','retainedSpans'])assert.equal(health[field],before[field]);assert.deepEqual(health.counts,before.counts);
assert.equal((await tracePost({resourceSpans:[]})).status,200);assert.equal((await tracePost({resourceSpans:[{scopeSpans:[{spans:[span]}]}]})).status,200);const trace=await (await fetch(base+'/api/trace/'+span.traceId)).json();assert.equal(trace.spans[0].durationMs,0.000001); assert.equal((await tracePost({resourceSpans:[]})).status,200);assert.equal((await tracePost({resourceSpans:[{scopeSpans:[{spans:[span]}]}]})).status,200);const trace=await (await fetch(base+'/api/trace/'+span.traceId)).json();assert.equal(trace.spans[0].durationMs,0.000001);
const beforeRestart=await (await fetch(base+'/health')).json();child.kill();await new Promise(resolve=>child.once('exit',resolve));
child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1',DEBUG_COLLECTOR_MAX_REQUESTS:'2',DEBUG_COLLECTOR_MAX_BUFFERED_BYTES:'4096'}});child.stdout.on('data',buffer=>output+=buffer);child.stderr.on('data',buffer=>output+=buffer);
let restarted;for(let n=0;n<80;n++){if(child.exitCode!==null)throw Error('Protected collector restart failed');try{restarted=await (await fetch(base+'/health')).json();break;}catch{await new Promise(resolve=>setTimeout(resolve,100));}}assert.ok(restarted);assert.equal(restarted.requests,0);for(const field of ['storedBytes','activeBytes','legacyBytes','retiredBytes','retiredSegments'])assert.equal(restarted[field],beforeRestart[field]);
}); });
+30 -2
View File
@@ -17,6 +17,34 @@ test('inflight retirement cancellation remains a failure with bounded facts and
test('failure history is bounded while lifetime reason totals retain every failed export',{timeout:15000},async t=>{ test('failure history is bounded while lifetime reason totals retain every failed export',{timeout:15000},async t=>{
const endpoint=await receiver(t,(req,res)=>{req.resume();res.writeHead(400,'Synthetic error body');res.end('PRIVATE_BODY_NOT_RETAINED');});const d=createDiagnostics();t.after(()=>d.shutdown());d.configure(config(endpoint));for(let i=0;i<18;i++)await produce(d);for(let i=0;i<100&&d.status().lifetimeFailedExports<2;i++)await delay(10);const status=d.status();assert.equal(status.lifetimeFailedExports,36);assert.equal(status.failureReasons['http-client'],36);assert.equal(status.failureFacts.length,32);assert.ok(!JSON.stringify(status).includes('PRIVATE_BODY_NOT_RETAINED')); const endpoint=await receiver(t,(req,res)=>{req.resume();res.writeHead(400,'Synthetic error body');res.end('PRIVATE_BODY_NOT_RETAINED');});const d=createDiagnostics();t.after(()=>d.shutdown());d.configure(config(endpoint));for(let i=0;i<18;i++)await produce(d);for(let i=0;i<100&&d.status().lifetimeFailedExports<2;i++)await delay(10);const status=d.status();assert.equal(status.lifetimeFailedExports,36);assert.equal(status.failureReasons['http-client'],36);assert.equal(status.failureFacts.length,32);assert.ok(!JSON.stringify(status).includes('PRIVATE_BODY_NOT_RETAINED'));
}); });
test('concurrent rejected flush cannot erase held native export attribution',{timeout:15000},async t=>{ test('concurrent flush shares held native exports and cancellation attribution',{timeout:15000},async t=>{
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),exportTimeoutMs:5000},{policyVersion:1});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);await d.span('browser.request',{},async()=>{});const second=d.flush();for(let i=0;i<100&&!d.status().failureReasons['concurrency-limit'];i++)await delay(10);assert.ok(d.status().failureReasons['concurrency-limit']>=1);d.configure({enabled:false});await Promise.all([first,second]);const status=d.status();assert.equal(received,2);assert.equal(status.failureReasons.cancelled,2);assert.equal(status.lifetimeFailedExports,status.failureReasons.cancelled+status.failureReasons['concurrency-limit']);assert.ok(status.failureFacts.filter(f=>f.reason==='cancelled').every(f=>f.cancelSignalled&&f.retirementOverlapped&&f.generation===1)); let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),exportTimeoutMs:5000},{policyVersion:1});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);await d.span('browser.request',{},async()=>{});const second=d.flush();assert.equal(second,first);await delay(50);assert.equal(d.status().failureReasons['concurrency-limit'],undefined);d.configure({enabled:false});await Promise.all([first,second]);const status=d.status();assert.equal(received,2);assert.equal(status.failureReasons.cancelled,2);assert.equal(status.lifetimeFailedExports,status.failureReasons.cancelled);assert.ok(status.failureFacts.filter(f=>f.reason==='cancelled').every(f=>f.cancelSignalled&&f.retirementOverlapped&&f.generation===1));
});
test('scheduled batches queue behind held HTTP exports, expire explicitly and cancel without release',{timeout:15000},async t=>{
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000,flushIntervalMs:250,exportTimeoutMs:5000});
await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);
// Batch size triggers immediate processor export while the first real requests remain held.
for(let i=0;i<48;i++)await d.span('browser.request',{},async()=>{});
for(let i=0;i<200&&!d.status().failureReasons['queue-expired'];i++)await delay(10);
const waiting=d.status();assert.equal(received,2,'A queued batch overlapped a held native HTTP request');assert.ok(waiting.failureReasons['queue-expired']>=1);assert.equal(waiting.failureReasons['concurrency-limit'],undefined);assert.ok(waiting.failureFacts.filter(f=>f.reason==='queue-expired').every(f=>f.queueAgeMs>0&&f.durationMs>=f.queueAgeMs));
// Fresh pending batch must fail immediately on admin disable, without a network attempt.
for(let i=0;i<16;i++)await d.span('browser.request',{},async()=>{});await delay(10);const before=d.status().lifetimeFailedExports;d.configure({enabled:false});await first;const final=d.status();assert.equal(received,2);assert.ok(final.lifetimeFailedExports>=before+2);assert.ok(final.failureReasons.cancelled>=2);assert.equal(final.failedExports,0);
});
test('bounded export FIFO reports overflow and cancels queued callbacks using real held HTTP',{timeout:15000},async t=>{
const {readFileSync}=await import('node:fs'),{runInNewContext}=await import('node:vm');const nativeRequire=createRequire(import.meta.url),captured=[];const sdk=nativeRequire('@opentelemetry/sdk-trace');
class CapturingProcessor extends sdk.BatchSpanProcessor{constructor(options){const original=options.exporter.export;options.exporter.export=function(records,callback){captured.push({exporter:options.exporter,records});return original.call(this,records,callback);};super(options);}}
const sandbox={module:{exports:{}},require:name=>name==='@opentelemetry/sdk-trace'?{...sdk,BatchSpanProcessor:CapturingProcessor}:name.startsWith('../../')?nativeRequire('../apps/browser/'+name):nativeRequire(name),setTimeout,clearTimeout,setImmediate,URL,console};runInNewContext(readFileSync(new URL('../apps/browser/diagnostics.cjs',import.meta.url),'utf8'),sandbox);
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=sandbox.module.exports.createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,exportTimeoutMs:5000});await d.span('browser.request',{},async()=>{});const pending=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);
const {exporter,records}=captured[0],results=[];exporter.export(Array.from({length:16},()=>records[0]),r=>results.push(r));exporter.export([records[0]],r=>results.push(r));assert.equal(d.status().failureReasons['queue-overflow'],1);assert.equal(results.length,1);assert.equal(results[0].code,1);assert.equal(received,2);d.configure({enabled:false});await pending;assert.equal(results.length,2);assert.ok(results.every(r=>r.code===1));assert.equal(d.status().failureReasons.cancelled,3);assert.equal(d.status().lifetimeFailedExports,4);assert.equal(received,2);
});
test('held HTTP success drains scheduled FIFO with one request per signal',{timeout:15000},async t=>{
let received=0,released=false;const held=[],active=new Map(),maximum=new Map();const endpoint=await receiver(t,(req,res)=>{const signal=req.url;received++;active.set(signal,(active.get(signal)||0)+1);maximum.set(signal,Math.max(maximum.get(signal)||0,active.get(signal)));res.once('finish',()=>active.set(signal,active.get(signal)-1));req.resume();if(released){res.writeHead(200,{'content-type':'application/json'});res.end('{}');}else held.push(res);});
const d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,exportTimeoutMs:5000});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);for(let i=0;i<16;i++)await d.span('browser.request',{},async()=>{});await delay(30);assert.equal(received,2);released=true;for(const res of held){res.writeHead(200,{'content-type':'application/json'});res.end('{}');}await first;await d.flush();for(let i=0;i<100&&received<4;i++)await delay(10);assert.equal(received,4,JSON.stringify({status:d.status(),signals:[...maximum.keys()]}));assert.equal(d.status().lifetimeFailedExports,0);assert.deepEqual([...maximum.values()],[1,1]);
});
test('identity and exporter settings changes still immediately cancel real held requests',{timeout:15000},async t=>{
for(const kind of ['identity','settings']){let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());const settings={...config(endpoint),exportTimeoutMs:5000},deviceId='00000000-0000-4000-8000-000000000001';d.configure(settings,{deviceId,policyVersion:1});await d.span('browser.request',{},async()=>{});const pending=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);const next=kind==='identity'?settings:{...settings,maxQueueSize:16},context={deviceId:kind==='identity'?'00000000-0000-4000-8000-000000000002':deviceId,policyVersion:2};assert.equal(d.canContinue(next,context),false);d.configure(next,context);await pending;assert.equal(received,2);assert.equal(d.status().lifetimeFailedExports,2);assert.equal(d.status().failedExports,0);assert.equal(d.status().failureReasons.cancelled,2);assert.ok(d.status().failureFacts.every(f=>f.cancelSignalled&&!f.activeAtCompletion&&f.retirementOverlapped));d.configure({enabled:false});}
}); });
+9
View File
@@ -85,3 +85,12 @@ test('invalid persisted IDs clear unrelated parents without getters, retries or
const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count); const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);
diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3); diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3);
}); });
test('compatible policy metadata updates preserve held exports and snapshot old operation context',{timeout:15000},async t=>{
const requests=[],held=[];let releaseHTTP=false;const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({path:req.url,payload:JSON.parse(Buffer.concat(chunks).toString())});if(releaseHTTP){res.setHeader('content-type','application/json');res.end('{}');}else held.push(res);});}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(()=>new Promise(r=>{server.closeAllConnections();server.close(r);}));
const d=createDiagnostics();t.after(()=>d.shutdown());const settings=config(`http://127.0.0.1:${server.address().port}`),deviceId='00000000-0000-4000-8000-000000000001';d.configure(settings,{policyVersion:1,deviceId});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&held.length<2;i++)await delay(10);assert.equal(held.length,2);
let finishOld,oldEntered;const oldReady=new Promise(r=>oldEntered=r),oldGate=new Promise(r=>finishOld=r);const old=d.span('browser.navigation',{},async()=>{oldEntered();await oldGate;await d.span('browser.clipboard',{},async()=>d.event('browser.security',{}));d.spanSync('browser.policy',{},()=>{});});await oldReady;
assert.equal(d.canContinue(settings,{deviceId,policyVersion:2}),true);assert.equal(d.configure(settings,{deviceId,policyVersion:2}),true);assert.equal(d.status().lifetimeFailedExports,0);releaseHTTP=true;for(const res of held){res.setHeader('content-type','application/json');res.end('{}');}await first;finishOld();await old;await d.span('browser.tab',{},async()=>{});await d.span('browser.agent',{policyVersion:9},async()=>{});await d.flush();
const policy=row=>Number(row.attributes.find(a=>a.key==='safetynet.policy.version').value.intValue);for(const row of spans(requests)){assert.equal(policy(row),row.name==='browser.tab'?2:row.name==='browser.agent'?9:1);}for(const row of logs(requests)){const name=row.body.stringValue;assert.equal(policy(row),name==='browser.tab'?2:name==='browser.agent'?9:1);}assert.equal(d.status().lifetimeFailedExports,0);
for(const input of [{...settings,enabled:false},{...settings,mode:'operational'},{...settings,debugSampleRate:0},{...settings,maxQueueSize:16},{...settings,endpoint:settings.endpoint+'/other'}])assert.equal(d.canContinue(input,{deviceId,policyVersion:3}),false);assert.equal(d.canContinue(settings,{deviceId:'00000000-0000-4000-8000-000000000002',policyVersion:3}),false);
});
+17 -2
View File
@@ -16,11 +16,11 @@ const extracted=source.slice(source.indexOf('function applyPolicy(envelope)'),so
async function fixture(t){ async function fixture(t){
const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve)); const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));
const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}); const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000});
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config); t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config,{policyVersion:1,deviceId:'00000000-0000-4000-8000-000000000001'});
const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001'; const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001';
const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)}); const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)});
const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});}; const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});};
const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')}); const c=vm.createContext({require,diagnostics,diagnosticContext:extra=>({deviceId,policyVersion:c.policy?.version||0,...extra}),process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v,{deviceId,policyVersion:c.policy?.version||0});},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
c.localRelay={proxyRules:'127.0.0.1:45678',proxyBypassRules:'<-loopback>',invalidate:tracked('relay-invalidate')}; c.localRelay={proxyRules:'127.0.0.1:45678',proxyBypassRules:'<-loopback>',invalidate:tracked('relay-invalidate')};
const originalSetProxy=c.browserSession.setProxy;c.browserSession.setProxy=async settings=>{assert.equal(settings.mode,'fixed_servers');assert.equal(settings.proxyRules,c.localRelay.proxyRules);assert.equal(settings.proxyBypassRules,'<-loopback>');await originalSetProxy(settings);}; const originalSetProxy=c.browserSession.setProxy;c.browserSession.setProxy=async settings=>{assert.equal(settings.mode,'fixed_servers');assert.equal(settings.proxyRules,c.localRelay.proxyRules);assert.equal(settings.proxyBypassRules,'<-loopback>');await originalSetProxy(settings);};
vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])}; vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])};
@@ -43,3 +43,18 @@ test('enabled new policy exports only post-configuration stages after protected
test('signed verification failure prevents bookmark, proxy and policy mutations',async t=>{ test('signed verification failure prevents bookmark, proxy and policy mutations',async t=>{
const f=await fixture(t);const tampered=f.envelope();tampered.payload+='A';await assert.rejects(f.c.applyPolicy(tampered),/signature rejected/);await f.diagnostics.flush();assert.equal(f.calls.length,0);assert.equal(f.c.policy.version,1);assert.ok(f.spans().some(s=>s.status.code===2)); const f=await fixture(t);const tampered=f.envelope();tampered.payload+='A';await assert.rejects(f.c.applyPolicy(tampered),/signature rejected/);await f.diagnostics.flush();assert.equal(f.calls.length,0);assert.equal(f.c.policy.version,1);assert.ok(f.spans().some(s=>s.status.code===2));
}); });
test('verified policy-only update keeps identical telemetry authorization throughout the transition',async t=>{
const f=await fixture(t);f.c.policy=structuredClone(f.next);f.next.version=2;
await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
assert.ok(f.calls.filter(v=>v.name==='configure').every(v=>v.enabled===true));
for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','credential-revoke','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,true,name);
assert.equal(f.c.policy.version,2);assert.equal(f.diagnostics.status().lifetimeFailedExports,0);
});
test('expired previous policy cannot preserve telemetry authorization even with identical collector settings',async t=>{
const f=await fixture(t);f.c.policy={...structuredClone(f.next),expiresAt:new Date(Date.now()-1000).toISOString()};f.next.version=2;
await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
assert.equal(f.calls.find(v=>v.name==='browser-proxy').enabled,false);
assert.deepEqual(f.calls.filter(v=>v.name==='configure').map(v=>v.enabled),[false,true]);
});