2026-10-13 10:33:31 +02:00
'use strict' ;
2026-10-13 16:58:15 +02:00
const { validateStages } = require ( '../../packages/core/inspection-stages.cjs' );
2026-10-13 10:33:31 +02:00
const { AsyncLocalStorage } = require ( 'node:async_hooks' );
const { performance } = require ( 'node:perf_hooks' );
const { randomInt } = require ( 'node:crypto' );
const http = require ( 'node:http' ), https = require ( 'node:https' );
const { TracerProvider , BatchSpanProcessor , TraceIdRatioBasedSampler , ParentBasedSampler } = require ( '@opentelemetry/sdk-trace' );
const { LoggerProvider , BatchLogRecordProcessor } = require ( '@opentelemetry/sdk-logs' );
const { resourceFromAttributes } = require ( '@opentelemetry/resources' );
const { OTLPTraceExporter } = require ( '@opentelemetry/exporter-trace-otlp-http' );
const { OTLPLogExporter } = require ( '@opentelemetry/exporter-logs-otlp-http' );
2026-10-13 18:24:44 +02:00
const { trace , ROOT_CONTEXT , SpanStatusCode , TraceFlags } = require ( '@opentelemetry/api' );
2026-10-13 18:48:40 +02:00
const names = new Set ([ 'browser.window' , 'browser.network' , 'browser.ipc' , 'browser.agent' , 'browser.management' , 'browser.navigation' , 'browser.dns' , 'browser.request' , 'browser.request.headers' , 'browser.inspect.local' , 'browser.inspect.enterprise' , 'browser.download' , 'browser.clipboard' , 'browser.visual' , 'browser.password.fill' , 'browser.ai' , 'browser.policy' , 'browser.startup' , 'browser.parser' , 'browser.semantic' , 'browser.yara' , 'browser.icap' , 'browser.tab' , 'browser.security' , 'browser.evidence' ]);
2026-10-13 18:24:44 +02:00
const operations = new Set ([ 'audit.deliver' , 'evidence.observer.install' , 'evidence.observer.drain' , 'evidence.observer.dispose' , 'policy.verify' , 'policy.bookmarks' , 'policy.agent-reconcile' , 'policy.page-configure' , 'policy.audit' , 'evidence.capture' , 'evidence.authorize' , 'evidence.challenge' , 'evidence.encrypt' , 'evidence.send' , 'evidence.ack' , 'agent.request' , 'agent.challenge' , 'agent.decrypt' , 'agent.transport' , 'agent.authorize' , 'agent.audit' , 'agent.dispatch' , 'agent.response' , 'agent.project' , 'agent.encrypt' , 'library:organize-plan' , 'library:organize-apply' , 'application:state' , 'settings:get' , 'settings:update' , 'bookmarks:list' , 'bookmarks:mutate' , 'tabs:create' , 'tabs:activate' , 'tabs:close' , 'tabs:group' , 'state' , 'authorize-setting' , 'settings-update' , 'window-control' , 'chrome-layout' , 'share-url' , 'bookmarks-mutate' , 'enroll' , 'sso' , 'navigate' , 'back' , 'forward' , 'reload' , 'ai' , 'inspect-file' , 'agent-connection' , 'ai-panel' , 'vault-panel' , 'vault-status' , 'vault-create' , 'vault-unlock' , 'vault-lock' , 'vault-list' , 'vault-save' , 'vault-delete' , 'vault-fill' , 'vault-generate' , 'tab-create' , 'tab-activate' , 'tab-close' , 'tab-group' , 'sessions' , 'read' , 'inspect' , 'scroll' , 'click' , 'type' , 'select' , 'check' , 'screenshot' , 'copy' , 'cut' , 'paste' , 'scan' , 'download' , 'sync' , 'create' , 'activate' , 'close' , 'group' , 'policy' , 'request' , 'dns' , 'parse' , 'embed' , 'yara' , 'icap' ]);
2026-10-13 10:33:31 +02:00
const components = new Set ([ 'browser' , 'ipc' , 'agent' , 'management' , 'navigation' , 'network' , 'dns' , 'dlp' , 'inspector' , 'parser' , 'semantic' , 'yara' , 'icap' , 'download' , 'clipboard' , 'visual' , 'vault' , 'ai' , 'policy' , 'tabs' ]);
2026-10-13 18:48:40 +02:00
for ( const operation of [ 'history:list' , 'history:go' , 'window:state' , 'window:control' ]) operations . add ( operation );
2026-10-13 10:33:31 +02:00
const decisions = new Set ([ 'allow' , 'block' , 'deny' , 'unknown' ]), statuses = new Set ([ 'ok' , 'error' , 'cancelled' , 'timeout' ]);
const requestTypes = new Set ([ 'mainFrame' , 'subFrame' , 'stylesheet' , 'script' , 'image' , 'font' , 'object' , 'xhr' , 'ping' , 'cspReport' , 'media' , 'webSocket' , 'other' ]);
2026-10-13 16:58:15 +02:00
for ( const name of [ 'browser.upload.lease' , 'browser.upload.part' , 'browser.upload.release' , 'browser.upload.fault' ]) names . add ( name );
// 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' ]);
2026-10-13 10:33:31 +02:00
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 ;
else if ([ 'policyVersion' , 'bytes' , 'count' ]. includes ( key ) && Number . isSafeInteger ( value ) && value >= 0 ) out [ key === 'policyVersion' ? 'safetynet.policy.version' : `safetynet. ${ key } ` ] = value ;
else if ( key === 'durationMs' && Number . isFinite ( value ) && value >= 0 && value <= 86400000 ) out [ 'safetynet.duration.ms' ] = value ;
else if ( key === 'operation' && operations . has ( value )) out [ 'safetynet.operation' ] = value ;
else if ( key === 'component' && components . has ( value )) out [ 'safetynet.component' ] = value ;
else if ( key === 'decision' && decisions . has ( value )) out [ 'dlp.decision' ] = value ;
else if ( key === 'status' && statuses . has ( value )) out [ 'safetynet.status' ] = value ;
else if ( key === 'requestType' && requestTypes . has ( value )) out [ 'safetynet.request.type' ] = value ;
2026-10-13 16:58:15 +02:00
else if ( key === 'reason' && reasons . has ( value )) out [ 'safetynet.reason' ] = value ;
2026-10-13 10:33:31 +02:00
} return out ;}
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 };
2026-10-13 18:04:02 +02:00
function createDiagnostics (){ const storage = new AsyncLocalStorage (); let generation = null , configurationKey = '' , closed = false ; const retiring = new Set (); let activeSpans = 0 , lifetimeFailedExports = 0 ;
2026-10-13 16:58:15 +02:00
// 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. */ }}
2026-10-13 10:33:31 +02:00
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 ));}
2026-10-13 18:04:02 +02:00
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 ()};}
2026-10-13 10:33:31 +02:00
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 );}}
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 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 ;
} 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 )} : {})});
} 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 {}}
}
2026-10-13 18:24:44 +02:00
// Durable events may be delivered by a flusher started under another event's
// async context. Adopt only data-property IDs; never inherit that unrelated
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
function withCorrelation ( ids , fn ){
if ( typeof fn !== 'function' ) throw TypeError ( 'Diagnostics operation requires a function' );
const current = generation ; let parent = null ;
try {
if ( current ? . active && ids && typeof ids === 'object' &&! Array . isArray ( ids ) && [ Object . prototype , null ]. includes ( Object . getPrototypeOf ( ids ))){
const descriptors = Object . getOwnPropertyDescriptors ( ids ), traceId = descriptors . traceId ? . value , spanId = descriptors . spanId ? . value ;
if ( typeof traceId === 'string' && /^[a-f0-9]{32}$/ . test ( traceId ) && traceId !== '0' . repeat ( 32 ) && typeof spanId === 'string' && /^[a-f0-9]{16}$/ . test ( spanId ) && spanId !== '0' . repeat ( 16 )){
const rate = current . config . mode === 'debug' ? current . config . debugSampleRate : current . config . sampleRate ;
const sampled = randomInt ( 1000000 ) < Math . floor ( rate * 1000000 );
parent = { generation : current , span : trace . wrapSpanContext ({ traceId , spanId , traceFlags : sampled ? TraceFlags . SAMPLED : TraceFlags . NONE , isRemote : false })};
}
}
} catch { /* Invalid diagnostic metadata cannot affect protected delivery. */ }
return storage . run ( parent , fn );
}
2026-10-13 10:33:31 +02:00
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 ]);}
2026-10-13 18:24:44 +02:00
return { configure , span , 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 })};
2026-10-13 10:33:31 +02:00
}
module . exports = { createDiagnostics , attributes , endpointAllowed };