Audit native transports and retain secure export diagnostics

This commit is contained in:
Karl committed 2026-10-14 00:37:32 +02:00
1 parent 8a253eda89
commit 0a5df7398e
28 files changed
+636 -53

No files matched your search

+15 -9
View File
@@ -27,6 +27,10 @@ for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.
// Diagnostic reasons are fixed code identifiers, never exception messages,
// policy rule text, request content or arbitrary caller-supplied labels.
const reasons=new Set(['request-source-context-changed','header-source-context-changed','headers-inspection-timeout','request-inspection-timeout','upload-lease-lost','upload-lease-release-failed','invalid-source-owner','policy-changed']);
names.add('browser.transport');
for(const operation of ['transport.install','transport.target','transport.dispose','transport.created','transport.closed','transport.established','transport.monitor-unavailable'])operations.add(operation);
// Keep the diagnostics cold path free of schema/SDK initialization.
for(const reason of ['transport-guard-unavailable','transport-observer-detached','transport-observer-command-timeout','transport-observer-overflow','transport-observer-audit-failed','transport-observer-unavailable','unexpected-webtransport-established'])reasons.add(reason);
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value;
if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value;
@@ -42,25 +46,27 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;}
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0;
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0,generationOrdinal=0;const failureReasons=Object.create(null),failureFacts=[];
// Parser timing is untrusted metadata: validate the entire batch before creating
// spans, and attach it only to a currently recording browser operation.
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs}));
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
}catch{/* Diagnostics must never change inspection results. */}}
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
function retire(current){if(!current)return;current.active=false;current.retiredAt=Date.now();for(const [request,attempt]of current.requests){attempt.cancelSignalled=true;const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);}for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
function failureReason(error,attempt){const code=error?.code;if(code==='SAFETYNET_DIAGNOSTICS_CANCELLED'||attempt.nativeCode==='SAFETYNET_DIAGNOSTICS_CANCELLED')return 'cancelled';if(code==='ETIMEDOUT'||error?.message==='Request timed out')return 'timeout';if(code==='ECONNREFUSED'||attempt.nativeCode==='ECONNREFUSED')return 'connection-refused';if(code==='ECONNRESET'||attempt.nativeCode==='ECONNRESET')return 'connection-reset';if(error?.message==='Concurrent export limit reached')return 'concurrency-limit';const status=Number.isInteger(code)?code:attempt.httpStatus;if(status>=300&&status<400)return 'http-redirect';if(status>=400&&status<500)return 'http-client';if(status>=500&&status<600)return 'http-server';return 'exporter-failure';}
function gatedExporter(exporter,current,signal){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}const attempt={startedAt:Date.now(),httpStatus:null,nativeCode:null,cancelSignalled:false};if(!current.attempts[signal])current.attempts[signal]=attempt;let finished=false;const done=result=>{if(finished)return;finished=true;if(current.attempts[signal]===attempt)delete current.attempts[signal];if(result.code!==0){current.failedExports++;lifetimeFailedExports++;const reason=failureReason(result.error,attempt);failureReasons[reason]=(failureReasons[reason]||0)+1;failureFacts.push({signal,generation:current.ordinal,reason,durationMs:Math.max(0,Date.now()-attempt.startedAt),activeAtCompletion:current.active,retirementOverlapped:!!current.retiredAt&&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 configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0};
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),requests:new Map(),attempts:Object.create(null),ordinal:++generationOrdinal,failedExports:0};
const agentFactory=async(protocol,signal)=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);return;}const attempt=current.attempts[signal];if(attempt){current.requests.set(request,attempt);request.once('close',()=>current.requests.delete(request));request.once('response',response=>{if(Number.isInteger(response.statusCode)&&response.statusCode>=100&&response.statusCode<=599)attempt.httpStatus=response.statusCode;});request.once('error',error=>{if(['SAFETYNET_DIAGNOSTICS_CANCELLED','ETIMEDOUT','ECONNREFUSED','ECONNRESET'].includes(error.code))attempt.nativeCode=error.code;});}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory});
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=(path,signal)=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:protocol=>agentFactory(protocol,signal)});
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules();
const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode});
const limits={maxQueueSize:config.maxQueueSize,maxExportBatchSize:Math.min(config.maxQueueSize,128),scheduledDelayMillis:config.flushIntervalMs,exportTimeoutMillis:config.exportTimeoutMs};
current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces')),current)})]});
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
current.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces','traces')),current,'traces')})]});
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs','logs')),current,'logs')})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
}catch{generation=null;retire(current);return false;}
}
function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return;
@@ -95,6 +101,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
}
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans,failureReasons:{...failureReasons},failureFacts:failureFacts.map(fact=>({...fact}))})};
}
module.exports={createDiagnostics,attributes,endpointAllowed};