Stabilize diagnostic export and preserve authorized policy continuity
This commit is contained in:
1 parent
0a5df7398e
commit
09bf616d98
18 files changed
+364
-57
No files matched your search
@@ -21,6 +21,11 @@ tested WebTransport lifecycle attempts without exposing raw browser debugging
|
||||
APIs. Its observation channel preserves clipboard protection; unsupported
|
||||
transport payloads remain blocked by the native relay boundary.
|
||||
|
||||
[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.
|
||||
|
||||
## A look inside
|
||||
|
||||
@@ -51,14 +51,24 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
// spans, and attach it only to a currently recording browser operation.
|
||||
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
|
||||
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs}));
|
||||
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
|
||||
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. */}}
|
||||
function retire(current){if(!current)return;current.active=false;current.retiredAt=Date.now();for(const [request,attempt]of current.requests){attempt.cancelSignalled=true;const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);}for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
|
||||
function failureReason(error,attempt){const code=error?.code;if(code==='SAFETYNET_DIAGNOSTICS_CANCELLED'||attempt.nativeCode==='SAFETYNET_DIAGNOSTICS_CANCELLED')return 'cancelled';if(code==='ETIMEDOUT'||error?.message==='Request timed out')return 'timeout';if(code==='ECONNREFUSED'||attempt.nativeCode==='ECONNREFUSED')return 'connection-refused';if(code==='ECONNRESET'||attempt.nativeCode==='ECONNRESET')return 'connection-reset';if(error?.message==='Concurrent export limit reached')return 'concurrency-limit';const status=Number.isInteger(code)?code:attempt.httpStatus;if(status>=300&&status<400)return 'http-redirect';if(status>=400&&status<500)return 'http-client';if(status>=500&&status<600)return 'http-server';return 'exporter-failure';}
|
||||
function gatedExporter(exporter,current,signal){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}const attempt={startedAt:Date.now(),httpStatus:null,nativeCode:null,cancelSignalled:false};if(!current.attempts[signal])current.attempts[signal]=attempt;let finished=false;const done=result=>{if(finished)return;finished=true;if(current.attempts[signal]===attempt)delete current.attempts[signal];if(result.code!==0){current.failedExports++;lifetimeFailedExports++;const reason=failureReason(result.error,attempt);failureReasons[reason]=(failureReasons[reason]||0)+1;failureFacts.push({signal,generation:current.ordinal,reason,durationMs:Math.max(0,Date.now()-attempt.startedAt),activeAtCompletion:current.active,retirementOverlapped:!!current.retiredAt&¤t.retiredAt>=attempt.startedAt,cancelSignalled:attempt.cancelSignalled,httpStatus:attempt.httpStatus});if(failureFacts.length>32)failureFacts.shift();}callback(result);};try{exporter.export(fresh,done);}catch(error){done({code:1,error});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
|
||||
function 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_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){
|
||||
// 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&¤t.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;
|
||||
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 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))));}
|
||||
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 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)});
|
||||
@@ -70,16 +80,16 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
}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;
|
||||
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{}
|
||||
}
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
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,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
|
||||
// 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();}
|
||||
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{}}
|
||||
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,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
|
||||
// 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)){
|
||||
const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;
|
||||
const sampled=randomInt(1000000)<Math.floor(rate*1000000);
|
||||
parent={generation:current,span:trace.wrapSpanContext({traceId,spanId,traceFlags:sampled?TraceFlags.SAMPLED:TraceFlags.NONE,isRemote:false})};
|
||||
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. */}
|
||||
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]);}
|
||||
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};
|
||||
@@ -240,7 +240,11 @@ async function applyPolicyImpl(envelope){
|
||||
// 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;
|
||||
}
|
||||
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 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();
|
||||
|
||||
@@ -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).
|
||||
@@ -84,6 +84,15 @@ that the whole application is clean, fully optimized or independently audited.
|
||||
|
||||
## 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
|
||||
passed 764 of765 and exposed a real gateway cancellation race: the policy
|
||||
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;
|
||||
mandatory inspection and audit durability must remain intact.
|
||||
|
||||
- Full source suite: 771 tests passed with `--test-concurrency=2`, no failures
|
||||
or skips; see `artifacts/transport-owner-final-failure-ledger-suite.log`. An earlier
|
||||
- Full source suite: 784 tests passed with `--test-concurrency=2`, no failures
|
||||
or skips; see `artifacts/diagnostics-continuity-final-suite.log`. An earlier
|
||||
higher-concurrency run failed one evidence-key ACL check; its cause remains
|
||||
unconfirmed and its log is retained. All 18 evidence tests passed separately.
|
||||
No ACL protection or timeout was weakened. `npm run lint` passed with zero
|
||||
@@ -143,9 +152,9 @@ mandatory inspection and audit durability must remain intact.
|
||||
literal and semantic payloads. Restart reused the protected device profile.
|
||||
These are separate from packaged-artifact acceptance. The final literal and
|
||||
actual-restart fixtures pass17+5 checks with zero lifetime exports failed.
|
||||
The latest semantic receiver/model checks pass, but diagnostics acceptance
|
||||
fails on three measured2s timeouts and one concurrency rejection. These are
|
||||
retained as real failures, not cancelled exports or successful delivery.
|
||||
The latest semantic receiver/model and diagnostics checks pass with zero
|
||||
lifetime failures. Earlier three measured2s timeouts and one concurrency
|
||||
rejection remain retained as real failures, with no retrospective cause claim.
|
||||
- The protected local OpenTelemetry receiver reported zero rejected writes.
|
||||
Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request,
|
||||
browser.inspect.local, inspector.inspect, semantic load/inference and durable
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The adjacent patch targets Chromium `152.0.7977.130`, used by Electron `44.7.0`.
|
||||
|
||||
@@ -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
|
||||
clipboard ownership. Earlier literal/restart and real local Gemma regressions
|
||||
passed on the same source hashes. The later final-source literal/restart test
|
||||
also passes; the later semantic DLP checks pass but its debug acceptance records
|
||||
three export timeouts and one concurrency rejection. Those failures remain
|
||||
open and are separate from receiver containment. These scenarios do not prove every worker, iframe,
|
||||
also passes. Historical semantic debug runs recorded three export timeouts and
|
||||
one concurrency rejection. The latest semantic and audited transport runs now
|
||||
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.
|
||||
|
||||
## Measured limits
|
||||
|
||||
@@ -7,12 +7,14 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap.
|
||||
|
||||
## 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
|
||||
Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests,
|
||||
Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen
|
||||
Gemma-DLP-Prüfungen bestehen, ihre Debug-Abnahme scheitert noch an drei
|
||||
Export-Timeouts und einer Concurrency-Ablehnung. WebTransport-Versuche in Fenster und Dedicated Worker sind nun
|
||||
Gemma-DLP-Prüfungen und ihre Debug-Abnahme bestehen nach begrenzten Exporten und
|
||||
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
|
||||
[Transport-Nachweise](native-transport-observability.md). Der bekannte native
|
||||
WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist.
|
||||
|
||||
+14
-10
@@ -55,20 +55,24 @@ Requests und zwei exakt übertragenen Dateien. Aktuelle und lebenslange
|
||||
Exportfehler bleiben in diesen Prozessen0. Der frische öffentliche Diagnosetest
|
||||
mit vier geprüften Policy-Veröffentlichungen besteht ebenfalls.
|
||||
|
||||
**Noch offen:** Die letzte echte lokale Gemma-Abnahme besteht alle vier
|
||||
DLP-/Empfängerprüfungen, scheitert aber an drei gemessenen2s-Export-Timeouts und
|
||||
einem `concurrency-limit`. Die neuen begrenzten Fehlerdaten belegen aktive
|
||||
Generation1 ohne Policy-Abbruch; sie beweisen noch nicht die Ursache der
|
||||
Antwortverzögerung. Kein Zähler wurde gelöscht oder eine Erwartung gelockert.
|
||||
Für die Debug-Abnahme sind begrenzte Export-Warteschlangen, ein gemeinsamer
|
||||
Flush und Latenz-/Eventloop-Nachweise zu prüfen. Ein weiterer geschützter
|
||||
Transporttest wurde nach diesem Fehler nicht einfach wiederholt.
|
||||
**Debug-Abnahme bestanden:** Nach begrenzter Export-FIFO, gemeinsamem Flush,
|
||||
SDK-Abschlussbarriere und signierter Policy-Kontinuität bestehen alle vier
|
||||
echten lokalen Gemma-Prüfungen und die vollständige WebTransport-Abnahme erneut
|
||||
mit null aktuellen und lebenslangen Exportfehlern. Native Clipboard-/Upload-
|
||||
und echte Neustarttests bestehen ebenfalls mit17+5 Prüfungen. Der Collector
|
||||
meldet578 Commits, null Fehler/Ablehnungen und maximal14.87ms Queue-Wartezeit.
|
||||
Timeout-Grenzen, Fehlerzähler und Abnahmeerwartungen wurden nicht gelockert.
|
||||
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.
|
||||
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:
|
||||
`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;
|
||||
dieser echte Gateway-Fehler ist mit deterministischen Tests und exportierten
|
||||
Traces behoben. Der stärker parallelisierte
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
@@ -3,6 +3,9 @@ import {createServer} from 'node:http';
|
||||
import {mkdirSync,existsSync,readFileSync,writeFileSync} from 'node:fs';
|
||||
import {collectorStorage} from './debug-collector-storage.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 {spawnSync} from 'node:child_process';
|
||||
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');
|
||||
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 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'});
|
||||
if(acl.status!==0)throw Error('Collector directory protection failed');
|
||||
protectCollectorDirectory(directory);
|
||||
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');
|
||||
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');
|
||||
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;
|
||||
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)=>{
|
||||
res.setHeader('cache-control','no-store');res.setHeader('x-content-type-options','nosniff');
|
||||
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'&&/^\/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;}
|
||||
const chunks=[];let size=0;req.on('data',chunk=>{size+=chunk.length;if(size>2*1024*1024)req.destroy();else chunks.push(chunk);});
|
||||
req.on('end',()=>{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';
|
||||
try{storage.append(line);}catch{res.writeHead(507);res.end('{"error":"Protected collector storage unavailable"}');return;}requests++;
|
||||
counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift();
|
||||
res.setHeader('content-type','application/json');res.end('{}');
|
||||
}catch{res.writeHead(400);res.end('{"error":"Invalid telemetry batch"}');}});
|
||||
const lease=admission.acquire();
|
||||
const rejectBusy=()=>{res.setHeader('connection','close');res.setHeader('retry-after','1');res.writeHead(503);res.end('{"error":"Collector busy"}');};
|
||||
if(!lease){rejectBusy();return;}
|
||||
const chunks=[];let finished=false;
|
||||
const release=()=>{lease.release();chunks.length=0;};
|
||||
const fail=(status,body)=>{if(finished)return;finished=true;release();res.setHeader('connection','close');res.writeHead(status);res.end(body);};
|
||||
req.once('aborted',()=>{finished=true;release();});req.once('error',()=>{finished=true;release();});
|
||||
res.once('close',()=>{if(!finished){finished=true;release();}});
|
||||
req.setTimeout(10000,()=>fail(408,'{"error":"Collector request timed out"}'));
|
||||
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);});
|
||||
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`));
|
||||
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);
|
||||
@@ -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});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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=>{
|
||||
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});});
|
||||
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));}}
|
||||
@@ -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);
|
||||
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);}}
|
||||
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);
|
||||
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'};
|
||||
@@ -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);
|
||||
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);
|
||||
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]);
|
||||
|
||||
});
|
||||
@@ -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=>{
|
||||
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=>{
|
||||
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));
|
||||
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();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});}
|
||||
});
|
||||
@@ -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);
|
||||
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);
|
||||
});
|
||||
@@ -16,11 +16,11 @@ const extracted=source.slice(source.indexOf('function applyPolicy(envelope)'),so
|
||||
async function fixture(t){
|
||||
const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));
|
||||
const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000});
|
||||
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config);
|
||||
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 envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)});
|
||||
const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});};
|
||||
const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
|
||||
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')};
|
||||
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||[])};
|
||||
@@ -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=>{
|
||||
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]);
|
||||
});
|
||||
Reference in new issue
Block a user