diff --git a/README.md b/README.md index 4cdfe26..40848c2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index cb7442a..34c1dd4 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -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){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}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index 811778f..fd38778 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -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(); diff --git a/docs/diagnostics-backpressure.md b/docs/diagnostics-backpressure.md new file mode 100644 index 0000000..dcdf954 --- /dev/null +++ b/docs/diagnostics-backpressure.md @@ -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). diff --git a/docs/electron-best-practices.md b/docs/electron-best-practices.md index 6763de6..415d51a 100644 --- a/docs/electron-best-practices.md +++ b/docs/electron-best-practices.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 diff --git a/docs/native-runtime/README.md b/docs/native-runtime/README.md index 374dfd3..188f971 100644 --- a/docs/native-runtime/README.md +++ b/docs/native-runtime/README.md @@ -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`. diff --git a/docs/native-transport-observability.md b/docs/native-transport-observability.md index e06ce28..12cae82 100644 --- a/docs/native-transport-observability.md +++ b/docs/native-transport-observability.md @@ -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 diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index 434e633..5837568 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -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. diff --git a/docs/windows-dlp-mvp.md b/docs/windows-dlp-mvp.md index d7fdf67..bb68815 100644 --- a/docs/windows-dlp-mvp.md +++ b/docs/windows-dlp-mvp.md @@ -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 diff --git a/scripts/debug-collector-acl.mjs b/scripts/debug-collector-acl.mjs new file mode 100644 index 0000000..b18a57e --- /dev/null +++ b/scripts/debug-collector-acl.mjs @@ -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}); +} diff --git a/scripts/debug-collector-admission.mjs b/scripts/debug-collector-admission.mjs new file mode 100644 index 0000000..51805c4 --- /dev/null +++ b/scripts/debug-collector-admission.mjs @@ -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})}; +} diff --git a/scripts/debug-collector.mjs b/scripts/debug-collector.mjs index 0a9648e..3de8a10 100644 --- a/scripts/debug-collector.mjs +++ b/scripts/debug-collector.mjs @@ -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); diff --git a/tests/debug-collector-acl.test.mjs b/tests/debug-collector-acl.test.mjs new file mode 100644 index 0000000..a7ee502 --- /dev/null +++ b/tests/debug-collector-acl.test.mjs @@ -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}); +}); diff --git a/tests/debug-collector-admission.test.mjs b/tests/debug-collector-admission.test.mjs new file mode 100644 index 0000000..e32b98b --- /dev/null +++ b/tests/debug-collector-admission.test.mjs @@ -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); +}); diff --git a/tests/debug-collector-server.test.mjs b/tests/debug-collector-server.test.mjs index f55804a..29bb277 100644 --- a/tests/debug-collector-server.test.mjs +++ b/tests/debug-collector-server.test.mjs @@ -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]); + }); diff --git a/tests/diagnostics-failure-facts.test.mjs b/tests/diagnostics-failure-facts.test.mjs index fcf4813..35d30b0 100644 --- a/tests/diagnostics-failure-facts.test.mjs +++ b/tests/diagnostics-failure-facts.test.mjs @@ -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});} }); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs index 389225d..5947934 100644 --- a/tests/diagnostics.test.mjs +++ b/tests/diagnostics.test.mjs @@ -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); +}); diff --git a/tests/policy-stage-diagnostics.test.mjs b/tests/policy-stage-diagnostics.test.mjs index f2f2acd..9d8f21e 100644 --- a/tests/policy-stage-diagnostics.test.mjs +++ b/tests/policy-stage-diagnostics.test.mjs @@ -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]); +});