Audit native transports and retain secure export diagnostics
This commit is contained in:
1 parent
8a253eda89
commit
0a5df7398e
28 files changed
+636
-53
No files matched your search
@@ -16,6 +16,11 @@ still open: a reproducible Chromium WebRTC renderer crash in initial blank
|
|||||||
iframes requires a verified native fix. Other platforms and broader features
|
iframes requires a verified native fix. Other platforms and broader features
|
||||||
remain outside this milestone.
|
remain outside this milestone.
|
||||||
|
|
||||||
|
[Native transport auditing](docs/native-transport-observability.md) records
|
||||||
|
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.
|
||||||
|
|
||||||
S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged.
|
S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged.
|
||||||
|
|
||||||
## A look inside
|
## A look inside
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.
|
|||||||
// Diagnostic reasons are fixed code identifiers, never exception messages,
|
// Diagnostic reasons are fixed code identifiers, never exception messages,
|
||||||
// policy rule text, request content or arbitrary caller-supplied labels.
|
// 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']);
|
const reasons=new Set(['request-source-context-changed','header-source-context-changed','headers-inspection-timeout','request-inspection-timeout','upload-lease-lost','upload-lease-release-failed','invalid-source-owner','policy-changed']);
|
||||||
|
names.add('browser.transport');
|
||||||
|
for(const operation of ['transport.install','transport.target','transport.dispose','transport.created','transport.closed','transport.established','transport.monitor-unavailable'])operations.add(operation);
|
||||||
|
// Keep the diagnostics cold path free of schema/SDK initialization.
|
||||||
|
for(const reason of ['transport-guard-unavailable','transport-observer-detached','transport-observer-command-timeout','transport-observer-overflow','transport-observer-audit-failed','transport-observer-unavailable','unexpected-webtransport-established'])reasons.add(reason);
|
||||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
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;
|
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;
|
if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value;
|
||||||
@@ -42,25 +46,27 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
|
|||||||
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
|
function 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;}
|
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};
|
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
|
||||||
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0;
|
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0,generationOrdinal=0;const failureReasons=Object.create(null),failureFacts=[];
|
||||||
// Parser timing is untrusted metadata: validate the entire batch before creating
|
// Parser timing is untrusted metadata: validate the entire batch before creating
|
||||||
// spans, and attach it only to a currently recording browser operation.
|
// spans, and attach it only to a currently recording browser operation.
|
||||||
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
|
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
|
||||||
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs}));
|
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs}));
|
||||||
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
|
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
|
||||||
}catch{/* Diagnostics must never change inspection results. */}}
|
}catch{/* Diagnostics must never change inspection results. */}}
|
||||||
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
|
function retire(current){if(!current)return;current.active=false;current.retiredAt=Date.now();for(const [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 gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
|
function 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 configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
|
function 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};
|
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),requests:new Map(),attempts:Object.create(null),ordinal:++generationOrdinal,failedExports:0};
|
||||||
const agentFactory=async protocol=>{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 agentFactory=async(protocol,signal)=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){const error=Error('Diagnostics cancelled');error.code='SAFETYNET_DIAGNOSTICS_CANCELLED';request.destroy(error);return;}const attempt=current.attempts[signal];if(attempt){current.requests.set(request,attempt);request.once('close',()=>current.requests.delete(request));request.once('response',response=>{if(Number.isInteger(response.statusCode)&&response.statusCode>=100&&response.statusCode<=599)attempt.httpStatus=response.statusCode;});request.once('error',error=>{if(['SAFETYNET_DIAGNOSTICS_CANCELLED','ETIMEDOUT','ECONNREFUSED','ECONNRESET'].includes(error.code))attempt.nativeCode=error.code;});}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
|
||||||
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
|
const agent=new PrivateAgent({keepAlive:false,maxSockets:1,rejectUnauthorized:true});current.agents.add(agent);return agent;};
|
||||||
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=path=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:agentFactory});
|
const base=config.endpoint.replace(/\/$/,'');const exporterConfig=(path,signal)=>({url:base+path,headers:{},timeoutMillis:config.exportTimeoutMs,concurrencyLimit:1,compression:'none',httpAgentOptions:protocol=>agentFactory(protocol,signal)});
|
||||||
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules();
|
const {TracerProvider,BatchSpanProcessor,TraceIdRatioBasedSampler,ParentBasedSampler,LoggerProvider,BatchLogRecordProcessor,resourceFromAttributes,OTLPTraceExporter,OTLPLogExporter}=loadTelemetryModules();
|
||||||
const resource=resourceFromAttributes({'service.name':'safetynet-browser','service.version':'0.1.0','safetynet.diagnostics.mode':config.mode});
|
const 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};
|
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.tracerProvider=new TracerProvider({resource,sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(config.mode==='debug'?config.debugSampleRate:config.sampleRate)}),spanLimits:{attributeCountLimit:20,attributeValueLengthLimit:64,eventCountLimit:0,linkCountLimit:0},spanProcessors:[new BatchSpanProcessor({...limits,exporter:gatedExporter(new OTLPTraceExporter(exporterConfig('/v1/traces','traces')),current,'traces')})]});
|
||||||
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs')),current)})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
|
current.loggerProvider=new LoggerProvider({resource,logRecordLimits:{attributeCountLimit:20,attributeValueLengthLimit:64},processors:[new BatchLogRecordProcessor({...limits,exporter:gatedExporter(new OTLPLogExporter(exporterConfig('/v1/logs','logs')),current,'logs')})]});current.tracer=current.tracerProvider.getTracer('safetynet.browser','0.1.0');current.logger=current.loggerProvider.getLogger('safetynet.browser','0.1.0');generation=current;return true;
|
||||||
}catch{generation=null;retire(current);return false;}
|
}catch{generation=null;retire(current);return false;}
|
||||||
}
|
}
|
||||||
function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return;
|
function event(name,input={}){const current=generation;if(!current?.active||!names.has(name))return;try{const parent=storage.getStore(),rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;if(parent?.generation===current){if(!parent.span.isRecording())return;}else if(randomInt(1000000)>=Math.floor(rate*1000000))return;
|
||||||
@@ -95,6 +101,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
|||||||
}
|
}
|
||||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
async function 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]);}
|
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||||
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans,failureReasons:{...failureReasons},failureFacts:failureFacts.map(fact=>({...fact}))})};
|
||||||
}
|
}
|
||||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||||
+27
-3
@@ -210,12 +210,12 @@ async function flushAudit(){
|
|||||||
try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}}
|
try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}}
|
||||||
catch{}finally{flushing=false;}
|
catch{}finally{flushing=false;}
|
||||||
}
|
}
|
||||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts,origin){
|
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts,origin,transportFacts){
|
||||||
if(!device||!policy)return;
|
if(!device||!policy)return;
|
||||||
// Operational correlation contains no rule text, hostname or forensic payload.
|
// Operational correlation contains no rule text, hostname or forensic payload.
|
||||||
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{}
|
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{}
|
||||||
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
|
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
|
||||||
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
|
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const transport=transportFacts?require('../../packages/core/transport-facts.cjs').transportFactsForEvent(kind,transportFacts):undefined;if(transportFacts&&!transport)throw Error('Transport audit metadata mismatch');const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(transport?{transport}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:Number.isSafeInteger(origin?.policyVersion)&&origin.policyVersion>0?origin.policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
|
||||||
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
|
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
|
||||||
}
|
}
|
||||||
function auditAgent(event){
|
function auditAgent(event){
|
||||||
@@ -695,6 +695,28 @@ function installSiteHooks(tab){
|
|||||||
audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);}
|
audit:event=>audit(event.kind,event.ruleId,event.host),block:result=>{security.visualBlocked={...result,message:result.reason==='credential-brand-impersonation'?'Blocked: this login resembles a protected brand on an unapproved origin.':'Blocked: this login could not be inspected safely.'};security.visualApproval=null;quarantine(security.visualBlocked.message);}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function installTransportObserver(tab){
|
||||||
|
const contents=tab.contents;
|
||||||
|
const current=context=>!!context&&context.deviceId===device?.id&&context.sessionId===tab.id&&tabs?.fromContents(contents.id)===tab&&!contents.isDestroyed();
|
||||||
|
const unavailable=reason=>{clipboardFatal=true;quarantine('Managed transport monitoring failed. Restart this browser.');try{tab.security.transportObserver?.dispose();}catch{}return diagnosticSpan('browser.transport',{operation:'transport.monitor-unavailable',sessionId:tab.id,reason,status:'error'},()=>audit('transport.monitor-unavailable',reason));};
|
||||||
|
tab.security.transportObserver=require('./native-transport-observer.cjs').createNativeTransportObserver({
|
||||||
|
channel:require('./page-clipboard-guard.cjs').createNativeTransportChannel(contents),
|
||||||
|
getContext:()=>ready&&policy&&device&&localRelay&&Date.parse(policy.expiresAt)>Date.now()?Object.freeze({deviceId:device.id,policyVersion:policy.version,sessionId:tab.id,documentGeneration:tab.security.interactionDocumentGeneration}):null,
|
||||||
|
isCurrent:current,
|
||||||
|
traceOperation:(operation,callback,context)=>diagnosticSpan('browser.transport',{operation,sessionId:context?.sessionId||tab.id,policyVersion:context?.policyVersion||policy?.version},callback),
|
||||||
|
// Synchronous native alarm precedes the serialized durable audit queue.
|
||||||
|
onEstablished:context=>{if(current(context)){clipboardFatal=true;quarantine('An uninspectable transport connected. Restart this browser.');}},
|
||||||
|
audit:async(facts,context)=>{
|
||||||
|
if(!current(context))return;
|
||||||
|
const {host,...transport}=facts;
|
||||||
|
const eventId=await diagnosticSpan('browser.transport',{operation:`transport.${transport.phase}`,sessionId:tab.id,policyVersion:context.policyVersion},()=>audit(`transport.${transport.phase}`,undefined,host,undefined,undefined,undefined,undefined,undefined,{policyVersion:context.policyVersion},transport));
|
||||||
|
if(typeof eventId!=='string')throw Error('Transport audit persistence failed');
|
||||||
|
if(transport.phase==='established')await unavailable('unexpected-webtransport-established');
|
||||||
|
},
|
||||||
|
onFailure:unavailable
|
||||||
|
});
|
||||||
|
return tab.security.transportObserver.install();
|
||||||
|
}
|
||||||
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
||||||
async function createNativeTabImpl(input={},controls={}){
|
async function createNativeTabImpl(input={},controls={}){
|
||||||
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
||||||
@@ -705,6 +727,8 @@ async function createNativeTabImpl(input={},controls={}){
|
|||||||
await tab.view.webContents.loadURL('about:blank');
|
await tab.view.webContents.loadURL('about:blank');
|
||||||
controls.assertCurrent?.();
|
controls.assertCurrent?.();
|
||||||
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
||||||
|
controls.assertCurrent?.();
|
||||||
|
await installTransportObserver(tab);
|
||||||
controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.();
|
controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.();
|
||||||
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();emit();return state();
|
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();emit();return state();
|
||||||
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
||||||
@@ -811,7 +835,7 @@ app.whenReady().then(async()=>{
|
|||||||
Menu.setApplicationMenu(null);
|
Menu.setApplicationMenu(null);
|
||||||
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
||||||
win.removeMenu();
|
win.removeMenu();
|
||||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>{tab.security.interactionObserver?.dispose();disposePageClipboardGuard(tab.contents);}});
|
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>{tab.security.interactionObserver?.dispose();Promise.resolve(tab.security.transportObserver?.dispose()).catch(()=>{});disposePageClipboardGuard(tab.contents);}});
|
||||||
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
||||||
clipboardGuard=createClipboardGuard({
|
clipboardGuard=createClipboardGuard({
|
||||||
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
const {randomUUID}=require('node:crypto');
|
||||||
|
const phases=Object.freeze({'Network.webTransportCreated':'created','Network.webTransportClosed':'closed','Network.webTransportConnectionEstablished':'established'});
|
||||||
|
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||||
|
const opaque=value=>typeof value==='string'&&value.length>0&&value.length<=128&&/^[a-zA-Z0-9._:-]+$/.test(value);
|
||||||
|
function transportHost(value){if(typeof value!=='string'||value.length>8192)throw Error('Transport metadata refused');const u=new URL(value);if(u.protocol!=='https:'||u.username||u.password||u.hostname.length>253)throw Error('Transport metadata refused');return u.hostname.toLowerCase();}
|
||||||
|
|
||||||
|
// Observability only: Chromium's mandatory private proxy remains the release
|
||||||
|
// boundary. No generic command runner, raw protocol events or page bridge escape.
|
||||||
|
function createNativeTransportObserver({channel,getContext,isCurrent,audit,onFailure,onEstablished,traceOperation=(_stage,fn)=>fn(),commandTimeoutMs=5000,maxTargets=64,maxAttempts=256,maxAuditQueue=256}={}){
|
||||||
|
if(!channel||['isHealthy','enableNetwork','autoAttachTargets','resumeTarget','subscribeMessage','subscribeDetach','release'].some(name=>typeof channel[name]!=='function')||[getContext,isCurrent,audit,onFailure,onEstablished,traceOperation].some(fn=>typeof fn!=='function')||!Number.isInteger(commandTimeoutMs)||commandTimeoutMs<50||commandTimeoutMs>10000||[maxTargets,maxAttempts,maxAuditQueue].some(n=>!Number.isInteger(n)||n<1||n>256))throw TypeError('Native transport observer callbacks required');
|
||||||
|
const targets=new Map(),attempts=new Map(),setups=new Set();let owned=false,installed=false,disposed=false,failed=false,installPromise,auditTail=Promise.resolve(),queued=0,unsubscribeMessage,unsubscribeDetach;
|
||||||
|
const active=()=>owned&&!disposed&&!failed;
|
||||||
|
function failure(reason='transport-observer-unavailable'){if(disposed||failed)return;failed=true;installed=false;attempts.clear();targets.clear();try{Promise.resolve(traceOperation('transport.monitor-unavailable',()=>onFailure(reason))).catch(()=>{});}catch{} }
|
||||||
|
async function command(method,_parameters={},sessionId){if(!active())throw Error('Native transport monitor unavailable');let timer;try{return await Promise.race([(method==='Network.enable'?channel.enableNetwork(sessionId):method==='Target.setAutoAttach'?channel.autoAttachTargets(sessionId):channel.resumeTarget(sessionId)),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Object.assign(Error('Native transport command timeout'),{reason:'transport-observer-command-timeout'})),commandTimeoutMs);})]);}finally{clearTimeout(timer);}}
|
||||||
|
function context(){try{const value=getContext();if(!value)return null;const copy=Object.freeze({...value});if(!uuid.test(copy.sessionId)||!Number.isSafeInteger(copy.policyVersion)||copy.policyVersion<1||!isCurrent(copy))return null;return copy;}catch{return null;}}
|
||||||
|
function enqueue(row,phase){if(queued>=maxAuditQueue){failure('transport-observer-overflow');return;}const facts=Object.freeze({transportId:row.id,protocol:'webtransport',phase,realm:row.realm,sessionId:row.context.sessionId,connectionEstablished:row.established,host:row.host});queued++;
|
||||||
|
// Metadata retains the creation policy/owner across later navigation/policy
|
||||||
|
// changes. Rechecking release authority here would erase legitimate audit.
|
||||||
|
auditTail=auditTail.then(async()=>{await traceOperation('transport.'+phase,()=>audit(facts,row.context),row.context);}).catch(()=>failure('transport-observer-audit-failed')).finally(()=>queued--);
|
||||||
|
}
|
||||||
|
function event(_event,method,params,sessionId){if(!active())return;
|
||||||
|
if(method==='Target.attachedToTarget'){
|
||||||
|
const id=params?.sessionId,type=params?.targetInfo?.type;
|
||||||
|
if(!opaque(id)||targets.has(id)){failure();return;}if(targets.size>=maxTargets){failure('transport-observer-overflow');return;}
|
||||||
|
const realm=['worker','shared_worker','service_worker'].includes(type)?'worker':type==='iframe'?'iframe':null;
|
||||||
|
if(!realm){failure();return;}targets.set(id,{realm});
|
||||||
|
const work=Promise.resolve().then(()=>traceOperation('transport.target',async()=>{
|
||||||
|
// Issue setup before resume, but do not await responses while the fresh
|
||||||
|
// target is waiting for startup. Chromium may defer them until resume.
|
||||||
|
const network=command('Network.enable',{maxPostDataSize:0},id),children=command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true},id);
|
||||||
|
await Promise.all([network,children,command('Runtime.runIfWaitingForDebugger',{},id)]);
|
||||||
|
})).catch(error=>failure(error.reason==='transport-observer-command-timeout'?'transport-observer-command-timeout':'transport-observer-unavailable')).finally(()=>setups.delete(work));setups.add(work);return;
|
||||||
|
}
|
||||||
|
// Target detachment can precede its final Network close event. Keep the
|
||||||
|
// already captured bounded attempt correlation until that event or disposal.
|
||||||
|
if(method==='Target.detachedFromTarget'){targets.delete(params?.sessionId);return;}
|
||||||
|
const phase=phases[method];if(!phase)return;
|
||||||
|
if(sessionId&&!targets.has(sessionId)&&phase==='created'){failure();return;}if(!opaque(params?.transportId)){failure();return;}
|
||||||
|
const key=(sessionId||'window')+':'+params.transportId;
|
||||||
|
if(phase==='created'){
|
||||||
|
if(attempts.has(key)){failure();return;}if(attempts.size>=maxAttempts){failure('transport-observer-overflow');return;}const owner=context();if(!owner)return;
|
||||||
|
try{const row={id:randomUUID(),context:owner,host:transportHost(params.url),realm:sessionId?targets.get(sessionId).realm:'window',target:sessionId,established:false};attempts.set(key,row);enqueue(row,phase);}catch{failure();}return;
|
||||||
|
}
|
||||||
|
const row=attempts.get(key);if(!row)return;
|
||||||
|
if(phase==='established'){if(row.established){failure();return;}row.established=true;try{onEstablished(row.context);}catch{failure();}enqueue(row,phase);return;}
|
||||||
|
attempts.delete(key);enqueue(row,phase);
|
||||||
|
}
|
||||||
|
function detach(){if(!disposed&&owned)failure('transport-observer-detached');owned=false;installed=false;}
|
||||||
|
async function install(){if(installPromise)return installPromise;if(disposed||failed||!channel.isHealthy())throw Error('Private transport observer unavailable');
|
||||||
|
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed error; protocol causes may contain private URLs or target data.
|
||||||
|
installPromise=Promise.resolve(traceOperation('transport.install',async()=>{try{unsubscribeMessage=channel.subscribeMessage(event);unsubscribeDetach=channel.subscribeDetach(detach);owned=true;await command('Network.enable',{maxPostDataSize:0});await command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true});if(!active())throw Error('Native transport monitor unavailable');installed=true;}catch(error){failure(error.reason==='transport-observer-command-timeout'?'transport-observer-command-timeout':'transport-observer-unavailable');throw Error('Native transport monitor installation failed');}}));return installPromise;
|
||||||
|
}
|
||||||
|
function dispose(){if(disposed)return;disposed=true;installed=false;attempts.clear();targets.clear();try{unsubscribeMessage?.();}catch{}try{unsubscribeDetach?.();}catch{}try{channel.release();}catch{}owned=false;}
|
||||||
|
async function flush(){await Promise.allSettled([...setups]);await auditTail;}
|
||||||
|
return {install,dispose,flush,status:()=>Object.freeze({installed,failed,disposed,attached:owned&&channel.isHealthy(),targets:targets.size,pending:attempts.size,queued})};
|
||||||
|
}
|
||||||
|
module.exports={createNativeTransportObserver};
|
||||||
@@ -24,7 +24,7 @@ function deadline(operation){let timer;return Promise.race([operation,new Promis
|
|||||||
async function attachPageClipboardGuard(contents,onFailure){
|
async function attachPageClipboardGuard(contents,onFailure){
|
||||||
const api=contents.debugger;
|
const api=contents.debugger;
|
||||||
if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable');
|
if(!api||api.isAttached()||owners.has(contents))throw Error('Page security initialization unavailable');
|
||||||
const owner={api,healthy:false};
|
const owner={api,healthy:false,onFailure};
|
||||||
const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();};
|
const detached=()=>{owners.delete(contents);owner.healthy=false;if(!contents.isDestroyed())onFailure();};
|
||||||
owner.detached=detached;
|
owner.detached=detached;
|
||||||
api.on('detach',detached);
|
api.on('detach',detached);
|
||||||
@@ -49,9 +49,41 @@ function guardedScreenshot(contents,parameters){
|
|||||||
}
|
}
|
||||||
function disposePageClipboardGuard(contents){
|
function disposePageClipboardGuard(contents){
|
||||||
const owner=owners.get(contents);if(!owner)return;
|
const owner=owners.get(contents);if(!owner)return;
|
||||||
|
owner.releaseTransport?.();
|
||||||
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
||||||
if(owner.api.isAttached())owner.api.detach();
|
if(owner.api.isAttached())owner.api.detach();
|
||||||
}
|
}
|
||||||
|
function createNativeTransportChannel(contents){
|
||||||
|
const owner=owners.get(contents),api=owner?.api;
|
||||||
|
if(!owner?.healthy||api!==contents.debugger||!api.isAttached()||owner.transportLease)throw Error('Private transport channel unavailable');
|
||||||
|
const lease=Symbol('native-transport'),subscriptions=new Set(),targets=new Set();let released=false;
|
||||||
|
owner.transportLease=lease;
|
||||||
|
const healthy=()=>!released&&owners.get(contents)===owner&&owner.healthy&&owner.transportLease===lease&&!contents.isDestroyed()&&api.isAttached();
|
||||||
|
const session=value=>value===undefined||typeof value==='string'&&value.length>0&&value.length<=128&&/^[a-zA-Z0-9._:-]+$/.test(value);
|
||||||
|
const command=(method,parameters,sessionId)=>{if(!healthy()||!session(sessionId))return Promise.reject(Error('Private transport channel unavailable'));return api.sendCommand(method,parameters,sessionId);};
|
||||||
|
const subscribe=(name,listener)=>{if(!healthy()||typeof listener!=='function')throw Error('Private transport subscription unavailable');api.on(name,listener);let removed=false;const remove=()=>{if(removed)return;removed=true;api.removeListener(name,listener);subscriptions.delete(remove);};subscriptions.add(remove);return remove;};
|
||||||
|
const messages=new Set(['Target.attachedToTarget','Target.detachedFromTarget','Network.webTransportCreated','Network.webTransportConnectionEstablished','Network.webTransportClosed']);
|
||||||
|
function release(){
|
||||||
|
if(released)return;released=true;for(const remove of [...subscriptions])remove();
|
||||||
|
if(owner.transportLease===lease){owner.transportLease=null;owner.releaseTransport=null;}
|
||||||
|
// Release private observation without detaching the clipboard security owner.
|
||||||
|
// Fresh workers may still be waiting for startup; resume before dropping targets.
|
||||||
|
if(api.isAttached()){
|
||||||
|
const send=(method,parameters,id)=>{try{return Promise.resolve(api.sendCommand(method,parameters,id));}catch{return Promise.reject(Error('Private transport cleanup unavailable'));}};
|
||||||
|
const pending=[];for(const id of targets)pending.push(send('Runtime.runIfWaitingForDebugger',{},id));
|
||||||
|
pending.push(send('Target.setAutoAttach',{autoAttach:false,waitForDebuggerOnStart:false,flatten:true}));
|
||||||
|
void Promise.allSettled(pending).then(results=>{if(results.some(result=>result.status==='rejected')&&owners.get(contents)===owner&&owner.healthy&&!contents.isDestroyed())owner.onFailure();});
|
||||||
|
}
|
||||||
|
targets.clear();
|
||||||
|
}
|
||||||
|
owner.releaseTransport=release;
|
||||||
|
return Object.freeze({isHealthy:healthy,
|
||||||
|
enableNetwork:sessionId=>command('Network.enable',{maxPostDataSize:0},sessionId),
|
||||||
|
autoAttachTargets:sessionId=>command('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true},sessionId),
|
||||||
|
resumeTarget:sessionId=>sessionId===undefined?Promise.reject(Error('Private transport target required')):command('Runtime.runIfWaitingForDebugger',{},sessionId),
|
||||||
|
subscribeMessage:listener=>subscribe('message',(event,method,params,sessionId)=>{if(!messages.has(method))return;if(method==='Target.attachedToTarget'&&session(params?.sessionId)&¶ms.sessionId!==undefined)targets.add(params.sessionId);if(method==='Target.detachedFromTarget')targets.delete(params?.sessionId);listener(event,method,params,sessionId);}),
|
||||||
|
subscribeDetach:listener=>subscribe('detach',()=>listener()),release});
|
||||||
|
}
|
||||||
async function guardedVisualStructure(contents){
|
async function guardedVisualStructure(contents){
|
||||||
const owner=owners.get(contents);
|
const owner=owners.get(contents);
|
||||||
if(!owner?.healthy||!owner.api.isAttached()||contents.isDestroyed())throw Error('Managed visual export refused');
|
if(!owner?.healthy||!owner.api.isAttached()||contents.isDestroyed())throw Error('Managed visual export refused');
|
||||||
@@ -71,4 +103,4 @@ async function guardedVisualStructure(contents){
|
|||||||
}
|
}
|
||||||
return {safe:true,nodes};
|
return {safe:true,nodes};
|
||||||
}
|
}
|
||||||
module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard};
|
module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard,createNativeTransportChannel};
|
||||||
@@ -16,18 +16,19 @@ export function approvedAIEndpoint(policy,config){
|
|||||||
}
|
}
|
||||||
export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}){
|
export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}){
|
||||||
return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{
|
return traceOperation('ai.gateway',{policyVersion:policy.version,bytes:Buffer.byteLength(prompt)},async()=>{
|
||||||
const controller=new AbortController();
|
const controller=new AbortController();let cancellationError;
|
||||||
const cancel=()=>controller.abort();
|
const abortWith=error=>{cancellationError??=error;controller.abort();};
|
||||||
|
const cancel=()=>abortWith(rejected(403,'AI authorization changed during processing','ai-authorization-changed'));
|
||||||
signal?.addEventListener('abort',cancel,{once:true});
|
signal?.addEventListener('abort',cancel,{once:true});
|
||||||
if(signal?.aborted)cancel();
|
if(signal?.aborted)cancel();
|
||||||
function assertCurrent(){
|
function assertCurrent(){
|
||||||
const active=store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(deviceId);
|
const active=store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(deviceId);
|
||||||
const policyChanged=active&&!isDeepStrictEqual(store.effectivePolicy(deviceId),policy);
|
const policyChanged=active&&!isDeepStrictEqual(store.effectivePolicy(deviceId),policy);
|
||||||
if(controller.signal.aborted||!active||Date.parse(policy.expiresAt)<=Date.now()||policyChanged){
|
if(controller.signal.aborted||!active||Date.parse(policy.expiresAt)<=Date.now()||policyChanged){
|
||||||
controller.abort();throw rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed');
|
abortWith(rejected(policyChanged?409:403,'AI authorization changed during processing','ai-authorization-changed'));throw cancellationError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const deadline=setTimeout(()=>controller.abort(),60000);
|
const deadline=setTimeout(()=>abortWith(rejected(504,'AI request timed out','ai-request-timeout')),60000);
|
||||||
const monitor=setInterval(()=>{try{assertCurrent();}catch{/* Abort pending provider work; release checks report the fixed failure. */}},100);
|
const monitor=setInterval(()=>{try{assertCurrent();}catch{/* Abort pending provider work; release checks report the fixed failure. */}},100);
|
||||||
monitor.unref();deadline.unref();
|
monitor.unref();deadline.unref();
|
||||||
try{
|
try{
|
||||||
@@ -62,7 +63,7 @@ export async function runApprovedAI({store,config,deviceId,policy,prompt,signal}
|
|||||||
const output=await traceOperation('ai.inspect.output',{bytes:Buffer.byteLength(answer),policyVersion:policy.version},()=>scan(answer));store.event(deviceId,'ai.output-decision',{...output,policyVersion:policy.version});
|
const output=await traceOperation('ai.inspect.output',{bytes:Buffer.byteLength(answer),policyVersion:policy.version},()=>scan(answer));store.event(deviceId,'ai.output-decision',{...output,policyVersion:policy.version});
|
||||||
if(output.decision==='block'){store.event(deviceId,'ai.output-block',{ruleId:output.ruleId,policyVersion:policy.version});throw rejected(403,'AI response blocked by company policy',output.ruleId);}
|
if(output.decision==='block'){store.event(deviceId,'ai.output-block',{ruleId:output.ruleId,policyVersion:policy.version});throw rejected(403,'AI response blocked by company policy',output.ruleId);}
|
||||||
assertCurrent();return answer;
|
assertCurrent();return answer;
|
||||||
}catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw e;}
|
}catch(e){store.event(deviceId,'ai.failure',{policyVersion:policy.version});throw cancellationError||e;}
|
||||||
finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();}
|
finally{clearTimeout(deadline);clearInterval(monitor);signal?.removeEventListener('abort',cancel);controller.abort();}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+7
-2
@@ -4,6 +4,7 @@ import transferFacts from '../../packages/core/transfer-facts.cjs';
|
|||||||
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
|
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
|
||||||
import agentAudit from '../../packages/core/agent-audit.cjs';
|
import agentAudit from '../../packages/core/agent-audit.cjs';
|
||||||
import interactionAudit from '../../packages/core/interaction-facts.cjs';
|
import interactionAudit from '../../packages/core/interaction-facts.cjs';
|
||||||
|
import transportAudit from '../../packages/core/transport-facts.cjs';
|
||||||
import {installEvidence} from './evidence.mjs';
|
import {installEvidence} from './evidence.mjs';
|
||||||
import {installIncidents} from './incidents.mjs';
|
import {installIncidents} from './incidents.mjs';
|
||||||
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
|
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
|
||||||
@@ -107,7 +108,11 @@ export function createApp(store,config) {
|
|||||||
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
|
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
|
||||||
});
|
});
|
||||||
app.post('/api/device/events',device,(req,res)=>{
|
app.post('/api/device/events',device,(req,res)=>{
|
||||||
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['browser.interaction','browser.interaction.dropped','dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.submission-completed','vault.submission.failure','vault.submission-prepared','vault.submission-authorized','vault.submission-denied','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body);
|
const b=z.object({eventId:z.string().uuid(),kind:z.enum([...transportAudit.TRANSPORT_KINDS,'browser.interaction','browser.interaction.dropped','dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.submission-completed','vault.submission.failure','vault.submission-prepared','vault.submission-authorized','vault.submission-denied','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional(),transport:transportAudit.TransportFactsSchema.optional()}).strict().parse(req.body);
|
||||||
|
if(b.kind.startsWith('transport.')){
|
||||||
|
const unavailable=b.kind==='transport.monitor-unavailable';
|
||||||
|
if(b.file||b.request||b.agent||b.interaction|| (unavailable?(b.host!==undefined||b.transport!==undefined||!transportAudit.TRANSPORT_MONITOR_REASONS.includes(b.ruleId)):(b.ruleId!==undefined||!transportAudit.canonicalTransportHostname(b.host)||!transportAudit.transportFactsForEvent(b.kind,b.transport))))return res.status(400).json({error:'Transport audit events require matching lifecycle metadata'});
|
||||||
|
}else if(b.transport!==undefined)return res.status(400).json({error:'Transport facts require a matching transport event'});
|
||||||
if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching lifecycle event'});
|
if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching lifecycle event'});
|
||||||
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
|
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
|
||||||
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
|
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
|
||||||
@@ -269,7 +274,7 @@ export function createApp(store,config) {
|
|||||||
app.use((error,_req,res,_next)=>{
|
app.use((error,_req,res,_next)=>{
|
||||||
config.onError?.(error);
|
config.onError?.(error);
|
||||||
const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed';
|
const invalid=error instanceof z.ZodError || error.type==='entity.parse.failed';
|
||||||
res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})});
|
res.status(invalid?400:error.type==='entity.too.large'?413:[400,403,404,409,504].includes(error.status)?error.status:500).json({error:invalid?'Request validation failed':'Request failed',...(error instanceof z.ZodError?{issues:error.issues.map(v=>({path:v.path.join('.'),message:v.message}))}:{})});
|
||||||
});
|
});
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -84,9 +84,55 @@ that the whole application is clean, fully optimized or independently audited.
|
|||||||
|
|
||||||
## Latest reproducible checks
|
## Latest reproducible checks
|
||||||
|
|
||||||
- Full source suite: 749 tests passed, no failures or skips; see the ignored
|
A subsequent full run after the Established-alarm regression added a test
|
||||||
`artifacts/mvp-final-startup-suite.log`. `npm run lint` passed again with
|
passed 764 of765 and exposed a real gateway cancellation race: the policy
|
||||||
zero warnings on 2026-10-13.
|
monitor aborted the provider but discarded its fixed409 error, producing500.
|
||||||
|
The gateway now preserves the first fixed cancellation reason (policy409,
|
||||||
|
caller403, deadline504). Fourteen focused tests and scoped lint passed. Actual
|
||||||
|
collector trace `3b6b5e374da506f7ae5ffbb118933bf6` shows the provider aborted at
|
||||||
|
101.6ms under the gateway and library parents. The earlier failed full run is
|
||||||
|
retained in `artifacts/transport-owner-containment-final-suite.log`.
|
||||||
|
|
||||||
|
The subsequent native clipboard/upload scenario passed all17 enforcement
|
||||||
|
checks but failed diagnostics acceptance because lifetime exports recorded two
|
||||||
|
errors. Collector rejected writes remained0; their exact causes cannot be
|
||||||
|
reconstructed from the original counters. Failure instrumentation and a fresh
|
||||||
|
acceptance run must resolve this separately from DLP transmission proof.
|
||||||
|
|
||||||
|
The browser now retains at most32 cloned export-failure facts and lifetime
|
||||||
|
totals by fixed reason. Facts include traces/logs, generation, duration, fixed
|
||||||
|
HTTP status, retirement overlap and explicitly signalled cancellation. Raw
|
||||||
|
error text, response bodies, credentials and collector URLs are excluded.
|
||||||
|
Actual held HTTP exports prove that retirement cancellation remains counted
|
||||||
|
as failure; the instrumentation does not silently reset or retry operations.
|
||||||
|
Seventeen focused diagnostics tests passed, including timeout, refusal,503,
|
||||||
|
bounded history and concurrent flush attribution.
|
||||||
|
|
||||||
|
A fresh public-only native diagnostic run completed with four independently
|
||||||
|
reviewed policy publications and four public HTTPS submissions. Every snapshot
|
||||||
|
had zero current/lifetime failures and an empty failure history; actual exported
|
||||||
|
trace `b5eefaf6bb8407f6917bd5cada6e8587` and collector rejectedWrites0 were
|
||||||
|
verified. Reproduction: `node scripts/native-diagnostics-public.mjs` with the
|
||||||
|
protected local collector already running. This does not retrospectively prove
|
||||||
|
the cause of the earlier two failures or reset their historical evidence.
|
||||||
|
|
||||||
|
A further quiet public-only native measurement delivered80 benign HTTPS
|
||||||
|
responses across four reviewed publications: median59.53ms, p95 83.19ms,
|
||||||
|
max88.17ms; observed main event-loop maximum drift328.29ms over637 samples.
|
||||||
|
Current/lifetime failures remained0, collector rejectedWrites0. Trace
|
||||||
|
`d4689e3d28ebe902b752500c0f79e30b` and
|
||||||
|
`artifacts/public-backpressure-native-proof.json` are local evidence. This does
|
||||||
|
not resolve or causally explain the semantic run's three timeouts and one
|
||||||
|
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
|
||||||
|
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
|
||||||
|
warnings after the final source changes.
|
||||||
- A fresh `npm audit --omit=dev --json` reported zero advisories. Development
|
- A fresh `npm audit --omit=dev --json` reported zero advisories. Development
|
||||||
toolchain findings remain tracked separately in `dependency-audit.md`.
|
toolchain findings remain tracked separately in `dependency-audit.md`.
|
||||||
- In the paired single module-load profiles, diagnostics require time changed
|
- In the paired single module-load profiles, diagnostics require time changed
|
||||||
@@ -95,7 +141,11 @@ that the whole application is clean, fully optimized or independently audited.
|
|||||||
measurements do not establish full application startup or percentile gains.
|
measurements do not establish full application startup or percentile gains.
|
||||||
- Native Windows DLP fixtures delivered public data and blocked confidential
|
- Native Windows DLP fixtures delivered public data and blocked confidential
|
||||||
literal and semantic payloads. Restart reused the protected device profile.
|
literal and semantic payloads. Restart reused the protected device profile.
|
||||||
These are separate from final packaged-artifact acceptance.
|
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 protected local OpenTelemetry receiver reported zero rejected writes.
|
- The protected local OpenTelemetry receiver reported zero rejected writes.
|
||||||
Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request,
|
Trace `33b46998b44c3179cccd2826d5790c00` correlates browser.request,
|
||||||
browser.inspect.local, inspector.inspect, semantic load/inference and durable
|
browser.inspect.local, inspector.inspect, semantic load/inference and durable
|
||||||
@@ -111,6 +161,12 @@ that the whole application is clean, fully optimized or independently audited.
|
|||||||
- The full enrolled browser WebTransport receiver fixture now passes through
|
- The full enrolled browser WebTransport receiver fixture now passes through
|
||||||
reviewed DIRECT and fetched HTTPS PAC policies: zero managed packets/bytes,
|
reviewed DIRECT and fetched HTTPS PAC policies: zero managed packets/bytes,
|
||||||
paired against 132 baseline bytes, with ordinary HTTPS submissions working.
|
paired against 132 baseline bytes, with ordinary HTTPS submissions working.
|
||||||
It records source hashes and genuine operational traces. There is still no
|
It records source hashes and genuine operational traces. A fixed private
|
||||||
per-attempt WebTransport security event because this path produces no
|
native channel now shares the existing clipboard debugger owner and records
|
||||||
Electron webRequest callback; transport denial and audit coverage are separate.
|
six Created and six Closed security events across the tested window and
|
||||||
|
dedicated-worker attempts, with zero Established events. All twelve events
|
||||||
|
correlate with exported audit traces. No raw URL, payload or CDP identifier
|
||||||
|
enters these events. See [native transport observability](native-transport-observability.md)
|
||||||
|
for coverage limits, failure behavior and ECS/CEF mapping. The private relay
|
||||||
|
remains the transmission boundary; lifecycle observations alone do not prove
|
||||||
|
content inspection or universal realm coverage.
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
This is a read-only review of the current source and unpacked Windows executable. It is not a penetration test or a claim of complete Chromium DLP coverage.
|
This is a read-only review of the current source and unpacked Windows executable. It is not a penetration test or a claim of complete Chromium DLP coverage.
|
||||||
|
|
||||||
|
Later entries record implementation and repeated native verification following
|
||||||
|
this initial review. For current WebTransport per-attempt auditing and its
|
||||||
|
coverage limits, use [native transport observability](native-transport-observability.md).
|
||||||
|
Historical missing-event findings below have been superseded by that work;
|
||||||
|
the separate native WebRTC engine crash remains an open acceptance gate.
|
||||||
|
|
||||||
## Official references
|
## Official references
|
||||||
|
|
||||||
Electron recommends isolated, sandboxed remote renderers, constrained IPC and navigation, current dependencies, and consideration of custom protocols and packaging fuses. [Security](https://www.electronjs.org/docs/latest/tutorial/security).
|
Electron recommends isolated, sandboxed remote renderers, constrained IPC and navigation, current dependencies, and consideration of custom protocols and packaging fuses. [Security](https://www.electronjs.org/docs/latest/tutorial/security).
|
||||||
@@ -81,6 +87,15 @@ Inspection/inference runs outside the main process, but synchronous audit encryp
|
|||||||
|
|
||||||
### Final package follow-up
|
### Final package follow-up
|
||||||
|
|
||||||
|
The subsequent shared clipboard/transport owner implementation also passed
|
||||||
|
actual Windows package/runtime and fuse negative verifiers. Its enrolled native
|
||||||
|
fixture now proves six Created and six Closed records, zero Established records,
|
||||||
|
and twelve correlated lifecycle/audit traces. Clipboard/restart and actual local
|
||||||
|
Gemma regressions passed with matching source hashes. This supersedes the
|
||||||
|
transport audit-gap statements below for the specifically tested Window and
|
||||||
|
dedicated Worker cases; it is not universal transport or payload coverage.
|
||||||
|
See [native transport observation](native-transport-observability.md).
|
||||||
|
|
||||||
The new frozen-source Windows build and both package verifiers passed after
|
The new frozen-source Windows build and both package verifiers passed after
|
||||||
the startup correction. Actual baseline, RUN_AS_NODE, NODE_OPTIONS, CLI
|
the startup correction. Actual baseline, RUN_AS_NODE, NODE_OPTIONS, CLI
|
||||||
inspector, eleven certificate/feature override launches and copied-ASAR tampering
|
inspector, eleven certificate/feature override launches and copied-ASAR tampering
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ Native evidence: `artifacts/webtransport-native-proof.json`. The receiver got
|
|||||||
zero packets/bytes with the private relay while ordinary HTTP loaded. The
|
zero packets/bytes with the private relay while ordinary HTTP loaded. The
|
||||||
Windows core DLP and real restart fixtures also passed with relay integration.
|
Windows core DLP and real restart fixtures also passed with relay integration.
|
||||||
Chromium upgrades must repeat this receiver proof; it is version-specific.
|
Chromium upgrades must repeat this receiver proof; it is version-specific.
|
||||||
WebTransport-specific denial audit events are not yet observed because the
|
The native rejection still occurs before HTTP/relay callbacks. A private native
|
||||||
native rejection occurs before existing HTTP/relay callbacks. Do not claim
|
observer now supplies real lifecycle records for the tested Window/dedicated
|
||||||
complete transport-attempt SIEM coverage from the ordinary DLP event pipeline.
|
Worker paths through enrollment and policy changes. See
|
||||||
|
[native transport observation](native-transport-observability.md). These are
|
||||||
|
Created/Closed observations, not inspected-payload decisions or universal
|
||||||
|
transport coverage. The relay remains the release boundary.
|
||||||
|
|
||||||
Inspected WebSocket/WebRTC support remains the intended compatibility goal.
|
Inspected WebSocket/WebRTC support remains the intended compatibility goal.
|
||||||
Uninspected transports remain restricted until their content gates have their
|
Uninspected transports remain restricted until their content gates have their
|
||||||
|
|||||||
@@ -105,3 +105,80 @@ attempts; its tagged binder source lacks the no-op implementation. A later
|
|||||||
binary is acceptable only after source inclusion checks and all native gates
|
binary is acceptable only after source inclusion checks and all native gates
|
||||||
above. Current ignored artifacts retain the original crashes and positive
|
above. Current ignored artifacts retain the original crashes and positive
|
||||||
controls; an upgrade must not overwrite that evidence.
|
controls; an upgrade must not overwrite that evidence.
|
||||||
|
|
||||||
|
## Local setup started, 2026-10-13
|
||||||
|
|
||||||
|
An isolated checkout now exists at
|
||||||
|
`C:/Users/karl/Safetynet/artifacts/native-build/electron44`.
|
||||||
|
Electron tag44.7.0 resolved to commit
|
||||||
|
`61f55c4ce540416b3e43f75f6c0f125d22e5e908`; its DEPS pins152.0.7977.130.
|
||||||
|
The adjacent depot_tools checkout is pinned at
|
||||||
|
`52ae103994896b9ce7742a605de8f43b410e5515`.
|
||||||
|
The ignored `run-sync.mjs` wrapper supplies process-only PATH/toolchain/Git
|
||||||
|
configuration and runs `gclient.bat sync --nohooks --shallow --jobs=2
|
||||||
|
--revision=src@2c592105bbcd9490a9894df48d0fe59b2c512651`.
|
||||||
|
It records `source-sync.log`, stops its own process tree if free disk falls below
|
||||||
|
150GiB, and bounds each run to30minutes. A live bootstrap is not source completion.
|
||||||
|
Current depot_tools expects `git.bat`; the first sync failed because only
|
||||||
|
`git.exe` was installed. An isolated `artifacts/native-build/bin/git.bat` now
|
||||||
|
forwards to the existing signed `C:/Program Files/Git/cmd/git.exe`; only the
|
||||||
|
wrapper's PATH includes that shim. The initial Chromium clone observed after
|
||||||
|
retry did not include a depth argument, so the requested shallow option must
|
||||||
|
not be treated as proof of a shallow fetch. Disk/deadline bounds still apply.
|
||||||
|
Hooks, patch application against Electron's full source, compilation and native
|
||||||
|
acceptance remain pending.
|
||||||
|
|
||||||
|
The controlled tagged bootstrap completed on2026-10-14 with exit0. Chromium
|
||||||
|
`152.0.7977.130` resolved to
|
||||||
|
`2c592105bbcd9490a9894df48d0fe59b2c512651`; Git reports a shallow repository.
|
||||||
|
Electron's pinned nested checkout was preserved. The backport's `git apply
|
||||||
|
--check` also passed against this **complete pristine Chromium checkout**.
|
||||||
|
Electron's dependency synchronization and native patch hooks have not yet
|
||||||
|
completed, so this does not replace the required check after Electron patches.
|
||||||
|
The bootstrap wrapper is `artifacts/native-build/run-bootstrap.mjs`; its log is
|
||||||
|
`bootstrap.log`. Initial fetch transferred a1.43GB pack; free disk after checkout
|
||||||
|
was259.3GiB. No compiler or native test was executed at this milestone.
|
||||||
|
|
||||||
|
The first dependency-sync attempt after bootstrap attempted a broad Chromium
|
||||||
|
remote fetch because the DEPS revision was a textual tag. That owned process
|
||||||
|
tree was explicitly stopped; its terminal exit was1. The matching local tag
|
||||||
|
was installed and the wrapper now overrides `src` with the exact existing
|
||||||
|
commit above, without modifying DEPS. The next run advanced into dependencies:
|
||||||
|
Node's `v24.21.0` checkout completed at
|
||||||
|
`955266bfdd854cd280dffd47548673914484e4c0`, followed by other Chromium
|
||||||
|
dependencies. At this documentation snapshot the bounded dependency run is
|
||||||
|
still active: dependency completion, patch hooks and compilation are **not
|
||||||
|
verified**. Gclient initial dependency clones may still fetch full histories
|
||||||
|
despite the shallow option; the disk floor and run deadline remain mandatory.
|
||||||
|
|
||||||
|
The same dependency sync was subsequently stopped deliberately to free resources
|
||||||
|
for failed application-test diagnosis. Its terminal exit was1; the owned process
|
||||||
|
tree was verified and stopped, without starting another sync. Chromium, Electron
|
||||||
|
and Node pins remain unchanged. Dawn, DevTools, ICU and Ink checkouts are present;
|
||||||
|
V8 and WebRTC dependency checkouts are still absent. Free disk at this checkpoint
|
||||||
|
was246.2GiB. This is a resumable preparation checkpoint, not a completed build.
|
||||||
|
|
||||||
|
### Missing ATL/MFC: explicit administrator step
|
||||||
|
|
||||||
|
Inspection found the existing installer at
|
||||||
|
`C:/Program Files (x86)/Microsoft Visual Studio/Installer/setup.exe`, version
|
||||||
|
4.10.30.62513. Its Authenticode status was **Valid**, signer Microsoft Corporation.
|
||||||
|
Existing installation:
|
||||||
|
`C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools`.
|
||||||
|
The official required component ID is
|
||||||
|
`Microsoft.VisualStudio.Component.VC.ATLMFC`.
|
||||||
|
|
||||||
|
The current agent process is not elevated. No UAC bypass or installer launch was
|
||||||
|
attempted. To install only this missing component, a user can run the following
|
||||||
|
in an **administrator PowerShell** and allow the normal installer flow:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& 'C:/Program Files (x86)/Microsoft Visual Studio/Installer/setup.exe' modify `
|
||||||
|
--installPath 'C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools' `
|
||||||
|
--add Microsoft.VisualStudio.Component.VC.ATLMFC --passive --norestart
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify afterward using `vswhere.exe -products '*' -requires
|
||||||
|
Microsoft.VisualStudio.Component.VC.ATLMFC -property installationPath`.
|
||||||
|
Treat installer exit3010 as restart required; do not claim build readiness until
|
||||||
|
the component is detected and toolchain checks pass.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Native WebTransport security observation
|
||||||
|
|
||||||
|
The private HTTP relay is the release boundary. Observation does not inspect
|
||||||
|
WebTransport payloads and cannot replace native transport containment.
|
||||||
|
|
||||||
|
Electron webRequest does not observe the tested Chromium WebTransport path.
|
||||||
|
The browser now subscribes privately to native Created, Established and Closed
|
||||||
|
events through the existing clipboard security owner's CDP connection.
|
||||||
|
The [Electron debugger API](https://www.electronjs.org/docs/latest/api/debugger)
|
||||||
|
and [Chromium Network protocol](https://chromedevtools.github.io/devtools-protocol/tot/Network/)
|
||||||
|
describe this instrumentation. No debugger port or protocol dispatcher is
|
||||||
|
exposed to websites, UI JavaScript or local agents.
|
||||||
|
|
||||||
|
## Ownership and lifecycle
|
||||||
|
|
||||||
|
The clipboard guard owns the only debugger attachment. Its fixed transport
|
||||||
|
lease can enable network observation, attach related targets and resume startup
|
||||||
|
targets; it cannot submit arbitrary commands or detach clipboard protection.
|
||||||
|
Initial about:blank navigation completes before setup commands, which Chromium
|
||||||
|
can otherwise defer indefinitely. Setup finishes before real page navigation.
|
||||||
|
Worker observation is queued before startup resume, covering the tested worker's
|
||||||
|
first statement. Disposal unsubscribes observation, resumes waiting targets and
|
||||||
|
releases auto-attachment while preserving clipboard protection.
|
||||||
|
|
||||||
|
Commands, targets, pending attempts and audit queues are bounded. Unexpected
|
||||||
|
detach, command timeout, overflow or audit failure makes the browser remain
|
||||||
|
quarantined until restart. Unexpected native establishment also quarantines
|
||||||
|
synchronously in the native event handler, before entering the serialized audit
|
||||||
|
queue. A held Created-audit regression verifies this ordering. This is a containment alarm;
|
||||||
|
it is not proof that an observer could prevent already transmitted bytes.
|
||||||
|
|
||||||
|
## Security records
|
||||||
|
|
||||||
|
`transport.created`, `transport.closed` and `transport.established` carry an
|
||||||
|
application-generated UUID, protocol, phase, realm, associated native tab UUID,
|
||||||
|
connection-established flag and canonical destination hostname. Creation policy
|
||||||
|
and owner context survive subsequent navigation or policy changes. Native CDP
|
||||||
|
identifiers, URLs, query strings, initiators, certificates, headers and bodies
|
||||||
|
are absent. Backend validation rejects contradictory phases, extra fields and
|
||||||
|
noncanonical host data. Monitor failures accept only fixed reason identifiers.
|
||||||
|
|
||||||
|
Records use the protected durable event pipeline. ECS and CEF preserve the
|
||||||
|
lifecycle facts as observations with unknown outcome: a Closed event is not
|
||||||
|
automatically claimed as an inspected or blocked payload. Operational traces
|
||||||
|
use fixed browser.transport stages and correlate audit encryption, persistence
|
||||||
|
and delivery without destination or content attributes. The signed diagnostics
|
||||||
|
switch still controls operational exports independently of security auditing.
|
||||||
|
|
||||||
|
## Actual Windows evidence
|
||||||
|
|
||||||
|
The current shared-owner native fixture proves Window and first-statement
|
||||||
|
dedicated Worker behavior through enrollment, reviewed DIRECT and fetched HTTPS
|
||||||
|
PAC→DIRECT policies. The unguarded control delivered 132 bytes. Managed cases
|
||||||
|
delivered zero packets or bytes, while three public HTTPS submissions worked.
|
||||||
|
Six Created and six Closed records reached the central backend; none Established.
|
||||||
|
All twelve exported phase traces have matching audit-stage parents. Ordinary
|
||||||
|
webRequest callbacks remain zero, so these are actual native observations.
|
||||||
|
|
||||||
|
Evidence: ignored `artifacts/fullmain-webtransport-native-proof.json`, source
|
||||||
|
hashes and `artifacts/cdp-webtransport-native-proof.json`. The latter additionally
|
||||||
|
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,
|
||||||
|
shared-worker attribution or future Chromium transport implementation.
|
||||||
|
|
||||||
|
## Measured limits
|
||||||
|
|
||||||
|
During concurrent native source checkout/build work, semantic paste took
|
||||||
|
2.9–3.7 seconds and public-file inspection 14.3 seconds. Protected audit fsync
|
||||||
|
reached 414 ms. These traces identify work to profile; they do not isolate an
|
||||||
|
observer regression or justify weakening scanning, isolation or persistence.
|
||||||
|
The separate initial-about:blank WebRTC engine crash remains unresolved until a
|
||||||
|
fixed native runtime is compiled and passes its paired receiver tests.
|
||||||
@@ -7,6 +7,18 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap.
|
|||||||
|
|
||||||
## Aktuelle nächste Abnahmen
|
## Aktuelle nächste Abnahmen
|
||||||
|
|
||||||
|
**Aktualisierung des Windows-MVP:** 771 Tests bestehen mit begrenzter
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
Die folgenden älteren Integrationszahlen beschreiben historische Zwischenstände;
|
||||||
|
die aktuelle verbindliche Abnahme steht im [Windows-MVP-Ziel](windows-dlp-mvp.md).
|
||||||
|
|
||||||
Aktueller Integrationsstand: 713 Tests bestehen
|
Aktueller Integrationsstand: 713 Tests bestehen
|
||||||
(`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte
|
(`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte
|
||||||
Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft:
|
Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft:
|
||||||
|
|||||||
+40
-5
@@ -48,8 +48,34 @@ erlaubte Ziele gelangen. Erst nach der folgenden Abnahme wird das MVP abgeschlos
|
|||||||
|
|
||||||
## Aktueller Abnahmestand
|
## Aktueller Abnahmestand
|
||||||
|
|
||||||
749 Tests bestehen im letzten vollständigen Lauf; ESLint verlangt null Fehler
|
**Letzter Quellstand:** Windows-Paket und beide Paketverifikationen bestehen;
|
||||||
und null Warnungen. Nachweis: `artifacts/mvp-final-startup-suite.log`.
|
Authenticode bleibt `NotSigned`. Native Clipboard-/Upload- und echte Neustarttests
|
||||||
|
bestehen erneut mit17+5 Prüfungen,13 öffentlichen Requests, null vertraulichen
|
||||||
|
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.
|
||||||
|
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;
|
||||||
|
ESLint verlangt null Fehler und null Warnungen. Nachweis:
|
||||||
|
`artifacts/transport-owner-final-failure-ledger-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
|
||||||
|
Lauf scheiterte an einer Evidence-Schlüssel-Rechteprüfung. Die kontrollierte
|
||||||
|
ACL-Nachprüfung war geschützt; alle 18 Evidence-Tests bestanden anschließend
|
||||||
|
separat. Die ursprüngliche Ausnahme enthält keine ausreichende Ursache, um
|
||||||
|
einen Timeout rückwirkend als bewiesen zu behaupten. Kein Schutz wurde gelockert.
|
||||||
Die neue native Windows-Abnahme besteht mit 17 Checks, einschließlich echtem
|
Die neue native Windows-Abnahme besteht mit 17 Checks, einschließlich echtem
|
||||||
Maus-Klick im contenteditable-Editor, Klartext/HTML-Clipboard, Policy-Race,
|
Maus-Klick im contenteditable-Editor, Klartext/HTML-Clipboard, Policy-Race,
|
||||||
YARA-/ICAP-Block und Scanner-Ausfall. Danach startete ein anderer Browserprozess
|
YARA-/ICAP-Block und Scanner-Ausfall. Danach startete ein anderer Browserprozess
|
||||||
@@ -61,7 +87,11 @@ mit null Exportfehlern. Nachweise: `artifacts/windows-dlp-mvp-process-restart-su
|
|||||||
`windows-dlp-mvp-first-process-native-proof.json` und
|
`windows-dlp-mvp-first-process-native-proof.json` und
|
||||||
`windows-dlp-mvp-restart-native-proof.json`.
|
`windows-dlp-mvp-restart-native-proof.json`.
|
||||||
Der aktuelle integrierte Relay-/Proxy-/Preload-Stand bestand diese native
|
Der aktuelle integrierte Relay-/Proxy-/Preload-Stand bestand diese native
|
||||||
Abnahme erneut: `artifacts/windows-dlp-mvp-current-frozen-native.log`.
|
Abnahme erneut. Der gemeinsame Clipboard-/Transport-Owner bestand danach
|
||||||
|
wieder alle 17 plus fünf echten Neustart-Checks:
|
||||||
|
`artifacts/transport-owner-mvp-native.log`. Auch die echte lokale Gemma-Abnahme
|
||||||
|
bestand erneut: `artifacts/transport-owner-semantic-native.log`; Quellhashes
|
||||||
|
blieben unverändert.
|
||||||
|
|
||||||
Das Gateway prüft normalisierte Textdarstellungen einzeln und revalidiert
|
Das Gateway prüft normalisierte Textdarstellungen einzeln und revalidiert
|
||||||
Policy/Widerruf vor Versand und Ausgabe. Tatsächliche ICAP-/Provider-Tests
|
Policy/Widerruf vor Versand und Ausgabe. Tatsächliche ICAP-/Provider-Tests
|
||||||
@@ -90,8 +120,13 @@ enrollte Browser übertrug null Pakete und Bytes, auch nach reviewed DIRECT und
|
|||||||
HTTPS-PAC→DIRECT-Policywechseln. Drei gewöhnliche HTTPS-POSTs funktionierten.
|
HTTPS-PAC→DIRECT-Policywechseln. Drei gewöhnliche HTTPS-POSTs funktionierten.
|
||||||
Nachweis: `artifacts/fullmain-webtransport-native-proof.json`, mit genauen
|
Nachweis: `artifacts/fullmain-webtransport-native-proof.json`, mit genauen
|
||||||
Quellhashes; spätere Änderungen betreffen ausschließlich Startparameterprüfung.
|
Quellhashes; spätere Änderungen betreffen ausschließlich Startparameterprüfung.
|
||||||
Die vor HTTP blockierten Versuche liefern keine webRequest-Callbacks und
|
Die vor HTTP blockierten Versuche liefern weiterhin keine webRequest-Callbacks.
|
||||||
damit weiterhin keine einzelnen DLP-Audit-Ereignisse. Diese SIEM-Grenze bleibt offen.
|
Der neue private native Observer erfasst jedoch tatsächlich sechs Created- und
|
||||||
|
sechs Closed-Lebenszyklen mit korrelierten Traces und ECS-/CEF-Metadaten.
|
||||||
|
Er teilt den begrenzten Kanal des Clipboard-Owners und trennt dessen Schutz
|
||||||
|
nicht. Die Reichweite ist Window und getesteter Dedicated Worker, keine
|
||||||
|
universelle Realm- oder Nutzlastprüfung. Details:
|
||||||
|
[Native Transportbeobachtung](native-transport-observability.md).
|
||||||
Ältere fehlgeschlagene Testläufe bleiben als solche erhalten.
|
Ältere fehlgeschlagene Testläufe bleiben als solche erhalten.
|
||||||
|
|
||||||
## Verbleibende Abnahmen
|
## Verbleibende Abnahmen
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import interactionAudit from './interaction-facts.cjs';
|
import interactionAudit from './interaction-facts.cjs';
|
||||||
|
import transportAudit from './transport-facts.cjs';
|
||||||
import agentAudit from './agent-audit.cjs';
|
import agentAudit from './agent-audit.cjs';
|
||||||
import transferFacts from './transfer-facts.cjs';
|
import transferFacts from './transfer-facts.cjs';
|
||||||
import requestTransferFacts from './request-transfer-facts.cjs';
|
import requestTransferFacts from './request-transfer-facts.cjs';
|
||||||
const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;};
|
const requestFacts=(metadata,kind)=>{if(!kind?.startsWith('upload.'))return null;const old=metadata.file;const parsed=requestTransferFacts.RequestFactsSchema.safeParse(metadata.request||(old?{bytes:old.bytes,detectedType:old.detectedType}:undefined));return parsed.success?parsed.data:null;};
|
||||||
const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;};
|
const facts=metadata=>{const parsed=transferFacts.FileFactsSchema.safeParse(metadata.file);return parsed.success?parsed.data:null;};
|
||||||
export function toECS(event,installationId='safetynet') {
|
export function toECS(event,installationId='safetynet') {
|
||||||
const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert';
|
const m=event.metadata,transport=transportAudit.canonicalTransportHostname(m.host)?transportAudit.transportFactsForEvent(event.kind,event.metadata.transport):null,transportEvent=transportAudit.TRANSPORT_KINDS.includes(event.kind),interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),block=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',failure=event.kind.endsWith('.failure')||agent?.outcome==='failed',alert=event.kind==='sigma.alert';
|
||||||
const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked';
|
const config=event.kind.startsWith('policy.')||event.kind.startsWith('enrollment.')||event.kind==='device.revoked';
|
||||||
const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.');
|
const auth=event.kind.startsWith('sso.'),clipboard=event.kind.startsWith('clipboard.');
|
||||||
return {
|
return {
|
||||||
'@timestamp':event.time,ecs:{version:'8.17.0'},
|
'@timestamp':event.time,ecs:{version:'8.17.0'},
|
||||||
event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security',
|
event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security',
|
||||||
action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'],
|
action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'],
|
||||||
type:[alert||interaction?.outcome==='observed'?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:interaction?.outcome==='observed'?'unknown':failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||interaction?.outcome==='observed'?'unknown':failure?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
||||||
observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'},
|
observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'},
|
||||||
...(event.device_id?{host:{id:event.device_id}}:{}),
|
...(event.device_id?{host:{id:event.device_id}}:{}),
|
||||||
...(m.actor?{user:{id:m.actor}}:{}),...(m.host?{destination:{domain:m.host}}:{}),
|
...(m.actor?{user:{id:m.actor}}:{}),...(m.host&&(!transportEvent||transport)?{destination:{domain:m.host}}:{}),
|
||||||
...(m.ruleId?{rule:{id:m.ruleId}}:{}),
|
...(m.ruleId?{rule:{id:m.ruleId}}:{}),
|
||||||
...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}),
|
...(event.kind.startsWith('download.')&&facts(m)?{file:{size:facts(m).bytes,type:facts(m).detectedType,hash:{sha256:facts(m).sha256}}}:{}),
|
||||||
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}),
|
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{trace:{id:m.traceId}}:{}),
|
||||||
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}),
|
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{span:{id:m.spanId}}:{}),
|
||||||
safetynet:{...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:m.decision??(block?'block':null),
|
safetynet:{...(transport?{transport:{transport_id:transport.transportId,protocol:transport.protocol,phase:transport.phase,realm:transport.realm,session_id:transport.sessionId,connection_established:transport.connectionEstablished}}:{}),...(event.kind==='transport.monitor-unavailable'&&transportAudit.TRANSPORT_MONITOR_REASONS.includes(m.ruleId)?{reason:m.ruleId}:{}),...(interaction?{interaction:{action:interaction.action,outcome:interaction.outcome,session_id:interaction.sessionId}}:{}),...(agent?{agent:{action:agent.action,scope:agent.scope,outcome:agent.outcome,...(agent.sessionId?{session_id:agent.sessionId}:{}),...(agent.principalId?{principal_id:agent.principalId,credential_generation:agent.credentialGeneration}:{})}}:{}),...(requestFacts(m,event.kind)?{request:{bytes:requestFacts(m,event.kind).bytes,detected_type:requestFacts(m,event.kind).detectedType}}:{}),...(event.audit?{audit:event.audit}:{}),policy_version:m.policyVersion??m.version??null,decision:transportEvent?null:m.decision??(block?'block':null),
|
||||||
content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null,
|
content_type:m.contentType??null,model:m.model??null,coverage:m.coverage??null,classification:m.classification==='local-only'?'local-only':null,
|
||||||
...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})}
|
...(alert?{source_event_id:`${installationId}:${m.sourceEventId}`,severity:m.level}:{})}
|
||||||
};
|
};
|
||||||
@@ -29,16 +30,16 @@ const header=v=>String(v).replace(/\\/g,'\\\\').replace(/\|/g,'\\|').replace(/[\
|
|||||||
const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n');
|
const ext=v=>String(v).replace(/\\/g,'\\\\').replace(/=/g,'\\=').replace(/\r/g,'\\r').replace(/\n/g,'\\n');
|
||||||
const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3);
|
const severity=level=>({low:3,medium:5,high:7,critical:10}[level]??3);
|
||||||
export function toCEF(event,installationId='safetynet') {
|
export function toCEF(event,installationId='safetynet') {
|
||||||
const m=event.metadata,interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',alert=event.kind==='sigma.alert';
|
const m=event.metadata,transport=transportAudit.canonicalTransportHostname(m.host)?transportAudit.transportFactsForEvent(event.kind,event.metadata.transport):null,transportEvent=transportAudit.TRANSPORT_KINDS.includes(event.kind),interaction=interactionAudit.interactionFactsForEvent(event.kind,event.metadata.interaction),agent=agentAudit.agentFactsForEvent(event.kind,event.metadata.agent),blocked=event.kind.includes('block')||event.kind.endsWith('.denied')||event.kind==='vault.submission-denied'||m.decision==='block',alert=event.kind==='sigma.alert';
|
||||||
// suid identifies the verified agent capability principal, not an employee account.
|
// suid identifies the verified agent capability principal, not an employee account.
|
||||||
// suser, when present, remains the independently supplied human actor.
|
// suser, when present, remains the independently supplied human actor.
|
||||||
const fields={...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind,
|
const fields={...(transport?{cs2Label:'TransportProtocol',cs2:transport.protocol,cs3Label:'TransportPhase',cs3:transport.phase,cs6Label:'TransportRealm',cs6:transport.realm,requestContext:transport.sessionId,cs1Label:'TransportId',cs1:transport.transportId,flexNumber2Label:'ConnectionEstablished',flexNumber2:transport.connectionEstablished?1:0}:{}),...(event.kind==='transport.monitor-unavailable'&&transportAudit.TRANSPORT_MONITOR_REASONS.includes(m.ruleId)?{reason:m.ruleId}:{}),...(interaction?{cs2Label:'InteractionAction',cs2:interaction.action,cs3Label:'InteractionOutcome',cs3:interaction.outcome,requestContext:interaction.sessionId}:{}),...(agent?{cs2Label:'AgentAction',cs2:agent.action,cs3Label:'AgentScope',cs3:agent.scope,cs6Label:'AgentOutcome',cs6:agent.outcome,...(agent.sessionId?{requestContext:agent.sessionId}:{}),...(agent.principalId?{suid:agent.principalId,flexNumber1Label:'AgentCredentialGeneration',flexNumber1:agent.credentialGeneration}:{})}:{}),...(requestFacts(m,event.kind)?{cn3Label:'RequestBytes',cn3:requestFacts(m,event.kind).bytes,cs6Label:'DetectedRequestType',cs6:requestFacts(m,event.kind).detectedType}:{}),...(event.audit?{flexString1Label:'AuditRecordHash',flexString1:event.audit.hash,flexString2Label:'AuditSequence',flexString2:event.audit.sequence}:{}),externalId:`${installationId}:${event.id}`,rt:Date.parse(event.time),act:event.kind,
|
||||||
...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}),
|
...(event.device_id?{deviceExternalId:event.device_id}:{}),...(m.actor?{suser:m.actor}:{}),
|
||||||
...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host?{dhost:m.host}:{}),
|
...(m.ruleId?{cs1Label:'RuleId',cs1:m.ruleId}:{}),...(m.host&&(!transportEvent||transport)?{dhost:m.host}:{}),
|
||||||
...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}),
|
...(m.policyVersion||m.version?{cn1Label:'PolicyVersion',cn1:m.policyVersion??m.version}:{}),
|
||||||
...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}),
|
...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}),
|
||||||
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}),
|
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}),
|
||||||
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}),
|
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}),
|
||||||
...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})};
|
...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})};
|
||||||
return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' ');
|
return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):!transportEvent&&blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' ');
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use strict';
|
||||||
|
const {z}=require('zod');
|
||||||
|
const TransportFactsSchema=z.object({transportId:z.string().uuid(),protocol:z.literal('webtransport'),phase:z.enum(['created','closed','established']),realm:z.enum(['window','worker','iframe']),sessionId:z.string().uuid(),connectionEstablished:z.boolean()}).strict().refine(f=>f.phase==='closed'||f.connectionEstablished===(f.phase==='established'));
|
||||||
|
const TRANSPORT_MONITOR_REASONS=Object.freeze(['transport-observer-detached','transport-observer-command-timeout','transport-observer-overflow','transport-observer-audit-failed','transport-observer-unavailable','unexpected-webtransport-established']);
|
||||||
|
const TRANSPORT_KINDS=Object.freeze(['transport.created','transport.closed','transport.established','transport.monitor-unavailable']);
|
||||||
|
function canonicalTransportHostname(host){
|
||||||
|
if(typeof host!=='string'||!host.length||host.length>253)return false;
|
||||||
|
try{const url=new URL(`https://${host}`);return url.hostname===host&&url.host===host&&url.username===''&&url.password===''&&url.port===''&&url.pathname==='/'&&url.search===''&&url.hash==='';}catch{return false;}
|
||||||
|
}
|
||||||
|
function transportFacts(value){return TransportFactsSchema.parse(value);}
|
||||||
|
function transportFactsForEvent(kind,value){const parsed=TransportFactsSchema.safeParse(value);return parsed.success&&kind===`transport.${parsed.data.phase}`?parsed.data:null;}
|
||||||
|
module.exports={canonicalTransportHostname,TransportFactsSchema,transportFacts,transportFactsForEvent,TRANSPORT_MONITOR_REASONS,TRANSPORT_KINDS};
|
||||||
@@ -6,9 +6,9 @@ if(mode==='managed'){const partition=session.fromPartition.bind(session),seenSes
|
|||||||
/* global WebTransport */
|
/* global WebTransport */
|
||||||
async function attemptWT(url,hash){const result={api:typeof WebTransport==='function',connected:false,datagramWritten:false,streamWritten:false,errorClass:null};let transport;try{transport=new WebTransport(url,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('timeout')),5000))]);result.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'),datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();result.datagramWritten=true;const stream=await transport.createUnidirectionalStream(),writer=stream.getWriter();await writer.write(bytes);await writer.close();result.streamWritten=true;await new Promise(r=>setTimeout(r,350));}catch(error){result.errorClass=['Error','TypeError','WebTransportError','NetworkError'].includes(error.name)?error.name:'OtherError';}finally{transport?.close();}return result;}
|
async function attemptWT(url,hash){const result={api:typeof WebTransport==='function',connected:false,datagramWritten:false,streamWritten:false,errorClass:null};let transport;try{transport=new WebTransport(url,{serverCertificateHashes:[{algorithm:'sha-256',value:new Uint8Array(hash)}]});await Promise.race([transport.ready,new Promise((_,reject)=>setTimeout(()=>reject(Error('timeout')),5000))]);result.connected=true;const bytes=new TextEncoder().encode('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'),datagram=transport.datagrams.writable.getWriter();await datagram.write(bytes);datagram.releaseLock();result.datagramWritten=true;const stream=await transport.createUnidirectionalStream(),writer=stream.getWriter();await writer.write(bytes);await writer.close();result.streamWritten=true;await new Promise(r=>setTimeout(r,350));}catch(error){result.errorClass=['Error','TypeError','WebTransportError','NetworkError'].includes(error.name)?error.name:'OtherError';}finally{transport?.close();}return result;}
|
||||||
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Full main WebTransport fixture timeout');}
|
const wait=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const value=await fn();if(value)return value;await wait(100);}throw Error('Full main WebTransport fixture timeout');}
|
||||||
async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};onmessage=async e=>{postMessage(await attempt(e.data.url,e.data.hash));close();};`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};worker.postMessage({url:${JSON.stringify(target+'/worker')},hash:${JSON.stringify(hashBytes)}});})`);return {window,worker};}
|
async function attemptRealms(page){const window=await page.executeJavaScript(`(${attemptWT.toString()})(${JSON.stringify(target)},${JSON.stringify(hashBytes)})`);const source=`const attempt=${attemptWT.toString()};attempt(${JSON.stringify(target+'/worker')},${JSON.stringify(hashBytes)}).then(async result=>{await new Promise(r=>setTimeout(r,200));postMessage(result);close();});`,worker=await page.executeJavaScript(`new Promise((resolve,reject)=>{const url=URL.createObjectURL(new Blob([${JSON.stringify(source)}],{type:'text/javascript'})),worker=new Worker(url),timer=setTimeout(()=>{worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker timeout'));},8000);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);resolve(e.data)};worker.onerror=()=>{clearTimeout(timer);worker.terminate();URL.revokeObjectURL(url);reject(Error('Worker fault'));};})`);return {window,worker,workerExecution:'first-statement'};}
|
||||||
app.whenReady().then(async()=>{let page,win,stage='startup';try{const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;if(mode==='baseline'){session.defaultSession.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});let callbacks=0;session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{callbacks++;callback({cancel:true});});win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});page=win.webContents;await win.loadURL(origin+'/workspace');const realms=await attemptRealms(page);assert.equal(callbacks,0);for(const realm of [realms.window,realms.worker])assert.ok(realm.connected&&realm.datagramWritten&&realm.streamWritten);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualElectron:true,mode,realms,webRequestCallbacks:callbacks,electron:process.versions.electron,chromium:process.versions.chrome},null,2));win.destroy();app.exit(0);return;}
|
app.whenReady().then(async()=>{let page,win,stage='startup';try{const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;if(mode==='baseline'){session.defaultSession.setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});let callbacks=0;session.defaultSession.webRequest.onBeforeRequest({urls:[target+'*']},(_details,callback)=>{callbacks++;callback({cancel:true});});win=new BrowserWindow({show:false,webPreferences:{sandbox:true,nodeIntegration:false,contextIsolation:true}});page=win.webContents;await win.loadURL(origin+'/workspace');const realms=await attemptRealms(page);assert.equal(callbacks,0);for(const realm of [realms.window,realms.worker])assert.ok(realm.connected&&realm.datagramWritten&&realm.streamWritten);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualElectron:true,mode,realms,webRequestCallbacks:callbacks,electron:process.versions.electron,chromium:process.versions.chrome},null,2));win.destroy();app.exit(0);return;}
|
||||||
win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay');
|
win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});for(const partition of ['persist:safetynet-managed','persist:safetynet-management'])session.fromPartition(partition).setCertificateVerifyProc((req,cb)=>{let valid=false;try{valid=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(valid?0:-2);});const base=process.env.SAFETYNET_SMOKE_SERVER,headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'},ui=async(method,value)=>{const response=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(response.ok,true);return response.data;};stage='actual enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');const phases=[];const test=async name=>{stage=name;const before=await(await fetch(process.env.SAFETYNET_WT_STATS)).json(),resolvedProxy=await page.session.resolveProxy(target);assert.notEqual(resolvedProxy,'DIRECT');const realms=await attemptRealms(page);for(const realm of [realms.window,realms.worker])assert.ok(!realm.connected&&!realm.datagramWritten&&!realm.streamWritten);const after=await(await fetch(process.env.SAFETYNET_WT_STATS)).json();for(const field of ['packets','connections','datagrams','streams','bytes','workerBytes'])assert.equal(after[field],before[field],'WebTransport reached receiver');assert.equal(await page.executeJavaScript("fetch('/receiver',{method:'POST',body:'Public full main ordinary prompt'}).then(r=>r.ok).catch(()=>false)"),true);phases.push({name,sessionId:(await ui('state')).activeTabId,policyVersion:(await ui('state')).policyVersion,proxyIsNonDirect:true,realms,receiverDelta:Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]])),benignHTTPSPostAllowed:true});};await test('enrolled private relay');
|
||||||
const change=async proxy=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.proxy=proxy;const proposal=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposal.status,202);const review=await proposal.json();const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');};stage='reviewed direct upstream policy';await change({mode:'direct'});await test('reviewed DIRECT upstream retains relay');stage='reviewed PAC direct upstream policy';await change({mode:'pac_script',pacUrl:origin+'/direct.pac'});await test('reviewed PAC DIRECT upstream retains relay');
|
const change=async proxy=>{const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.proxy=proxy;const proposal=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposal.status,202);const review=await proposal.json();const approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading()&&page.getURL()===origin+'/workspace');};stage='reviewed direct upstream policy';await change({mode:'direct'});await test('reviewed DIRECT upstream retains relay');stage='reviewed PAC direct upstream policy';await change({mode:'pac_script',pacUrl:origin+'/direct.pac'});await test('reviewed PAC DIRECT upstream retains relay');
|
||||||
stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),unsupportedTransportAuditGap:'Chromium WebTransport bypasses Electron webRequest; receiver absence is native containment evidence, not an attributed per-attempt DLP audit event.',diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites}},null,2));console.log('Full enrolled main WebTransport protection passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
stage='actual ordinary request observability';assert.equal(nativeWTCallbacks,0);await until(async()=>{const response=await fetch(base+'/api/admin/overview',{headers});assert.equal(response.status,200);const overview=await response.json();const rows=overview.events.filter(e=>e.kind.startsWith('transport.'));assert.ok(!rows.some(e=>e.kind==='transport.established'||e.kind==='transport.monitor-unavailable'));return rows.filter(e=>e.kind==='transport.created').length===6&&rows.filter(e=>e.kind==='transport.closed').length===6;});await actualDiagnostics.flush();await wait(1500);const events=[];let cursor=0;for(let batch=0;batch<20;batch++){const response=await fetch(base+`/api/admin/events/export?after=${cursor}&limit=1000&format=ecs`,{headers});assert.equal(response.status,200);const rows=(await response.text()).trim().split('\n').filter(Boolean).map(JSON.parse);events.push(...rows);if(rows.length<1000)break;cursor=Number(response.headers.get('x-safetynet-next-cursor'));}const transportEvents=events.filter(e=>e.event.action.startsWith('transport.'));assert.equal(transportEvents.filter(e=>e.event.action==='transport.created').length,6);assert.equal(transportEvents.filter(e=>e.event.action==='transport.closed').length,6);assert.equal(transportEvents.length,12);const created=new Map(),transportTraces=[];for(const e of transportEvents){const t=e.safetynet.transport;assert.ok(t);assert.deepEqual(Object.keys(t).sort(),['connection_established','phase','protocol','realm','session_id','transport_id']);assert.equal(t.protocol,'webtransport');assert.equal(t.connection_established,false);assert.deepEqual(e.event.type,['info']);assert.equal(e.event.outcome,'unknown');assert.equal(e.destination.domain,'127.0.0.1');assert.ok(['window','worker'].includes(t.realm));assert.equal(t.session_id,phases[0].sessionId);assert.ok(!JSON.stringify(e).includes(target));assert.ok(!JSON.stringify(e).includes(process.env.SAFETYNET_WT_HASH));assert.ok(!JSON.stringify(e).includes('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'));assert.ok(!/requestId|certificate|serverCertificateHashes|stackTrace|query/.test(JSON.stringify(e)));if(t.phase==='created'){assert.ok(!created.has(t.transport_id));created.set(t.transport_id,e);}else{const start=created.get(t.transport_id);assert.ok(start);assert.equal(start.safetynet.transport.realm,t.realm);assert.equal(start.safetynet.transport.session_id,t.session_id);assert.equal(start.safetynet.policy_version,e.safetynet.policy_version);}assert.ok(e.trace?.id);const exported=await(await fetch('http://127.0.0.1:4318/api/trace/'+e.trace.id)).json();assert.ok(!JSON.stringify(exported).includes(target));assert.ok(!JSON.stringify(exported).includes(process.env.SAFETYNET_WT_HASH));assert.ok(!JSON.stringify(exported).includes('SYNTHETIC_WEBTRANSPORT_DLP_MARKER'));assert.ok(exported.spans.some(s=>s.name==='browser.transport'&&s.operation==='transport.'+t.phase));const byId=new Map(exported.spans.map(s=>[s.spanId,s]));for(const stage of exported.spans.filter(s=>s.name==='browser.security'&&s.operation?.startsWith('audit.'))){let current=stage,linked=false;const seen=new Set();while(current&&!seen.has(current.spanId)){if(current.name==='browser.transport'){linked=true;break;}seen.add(current.spanId);current=byId.get(current.parentSpanId);}assert.ok(linked,'Transport audit stage parent missing');}transportTraces.push({phase:t.phase,realm:t.realm,transportId:t.transport_id,traceId:e.trace.id,stages:exported.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs}))});}for(const phase of phases){for(const realm of ['window','worker'])for(const eventPhase of ['created','closed'])assert.equal(transportEvents.filter(e=>e.safetynet.policy_version===phase.policyVersion&&e.safetynet.transport.realm===realm&&e.safetynet.transport.phase===eventPhase).length,1);}const event=events.filter(e=>e.event.action==='upload.authorized'&&e.trace?.id).at(-1);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.trace.id)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const diagnostics=actualDiagnostics.status();assert.equal(diagnostics.failedExports,0);assert.equal(diagnostics.lifetimeFailedExports,0);const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(process.env.SAFETYNET_WT_OUTPUT,JSON.stringify({actualFullMain:true,mode,phases,nativeWebRequestCallbacks:nativeWTCallbacks,ordinaryTraceId:event.trace.id,ordinaryStages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),nativeTransportAudit:{created:6,closed:6,established:0,firstStatementWorker:true,events:transportEvents,transportTraces,coverage:'Actual native Window and dedicated Worker lifecycles in these three policy phases; no claim for every realm or future transport implementation.'},diagnostics,health:{storedBytes:health.storedBytes,rejectedWrites:healthLine truncated
|
||||||
}catch(error){fs.writeFileSync(path.resolve('artifacts/fullmain-wt-native-error.json'),JSON.stringify({mode,stage,name:error.name},null,2));console.error('Full main WebTransport failed at '+stage+': '+error.name);if(mode==='managed'&&page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
}catch(error){fs.writeFileSync(path.resolve('artifacts/fullmain-wt-native-error.json'),JSON.stringify({mode,stage,name:error.name},null,2));console.error('Full main WebTransport failed at '+stage+': '+error.name);if(mode==='managed'&&page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||||
@@ -11,11 +11,11 @@ import {createApp} from '../apps/server/app.mjs';
|
|||||||
import {openStore,digest} from '../apps/server/store.mjs';
|
import {openStore,digest} from '../apps/server/store.mjs';
|
||||||
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||||
const collector='http://127.0.0.1:4318';assert.equal((await fetch(collector+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(),root=mkdtempSync(join(tmpdir(),'safetynet-fullmain-wt-'));mkdirSync('artifacts',{recursive:true});assert.ok(resolve(root).startsWith(resolve(tmpdir())+sep)&&root.split(/[\\/]/).at(-1).startsWith('safetynet-fullmain-wt-'));
|
const collector='http://127.0.0.1:4318';assert.equal((await fetch(collector+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(),root=mkdtempSync(join(tmpdir(),'safetynet-fullmain-wt-'));mkdirSync('artifacts',{recursive:true});assert.ok(resolve(root).startsWith(resolve(tmpdir())+sep)&&root.split(/[\\/]/).at(-1).startsWith('safetynet-fullmain-wt-'));
|
||||||
const files=['apps/browser/main.cjs','apps/browser/startup-security.cjs','apps/browser/local-dlp-relay.cjs','apps/browser/diagnostics.cjs','packages/core/policy.mjs','package.json'],sourceHashFacts=Object.fromEntries(files.map(file=>[file,createHash('sha256').update(readFileSync(file)).digest('hex')]));let python,page,management,store,child,stage='receiver startup';const ordinary={posts:0,pacRequests:0};
|
const files=['apps/browser/main.cjs','apps/browser/native-transport-observer.cjs','apps/browser/page-clipboard-guard.cjs','packages/core/transport-facts.cjs','packages/core/siem.mjs','apps/browser/startup-security.cjs','apps/browser/local-dlp-relay.cjs','apps/browser/diagnostics.cjs','packages/core/policy.mjs','package.json'],sourceHashFacts=Object.fromEntries(files.map(file=>[file,createHash('sha256').update(readFileSync(file)).digest('hex')]));let python,page,management,store,child,stage='receiver startup';const ordinary={posts:0,pacRequests:0};
|
||||||
try{python=spawn('python',['scripts/webtransport-receiver.py',root],{env:{...process.env,PYTHONPATH:resolve('artifacts/webtransport-python')},windowsHide:true,stdio:['ignore','pipe','pipe']});python.stderr.on('data',()=>{});const metadata=await new Promise((accept,reject)=>{let text='';const timer=setTimeout(()=>reject(Error('Pinned QUIC receiver timeout')),15000);python.stdout.on('data',chunk=>{text+=chunk;try{const value=JSON.parse(text.split('\n')[0]);clearTimeout(timer);accept(value);}catch{}});python.once('error',()=>reject(Error('Pinned QUIC receiver startup refused')));python.once('exit',()=>{clearTimeout(timer);reject(Error('Pinned QUIC receiver exited'));});});const stats=`http://127.0.0.1:${metadata.statsPort}/facts`,target=`https://127.0.0.1:${metadata.quicPort}/webtransport`;
|
try{python=spawn('python',['scripts/webtransport-receiver.py',root],{env:{...process.env,PYTHONPATH:resolve('artifacts/webtransport-python')},windowsHide:true,stdio:['ignore','pipe','pipe']});python.stderr.on('data',()=>{});const metadata=await new Promise((accept,reject)=>{let text='';const timer=setTimeout(()=>reject(Error('Pinned QUIC receiver timeout')),15000);python.stdout.on('data',chunk=>{text+=chunk;try{const value=JSON.parse(text.split('\n')[0]);clearTimeout(timer);accept(value);}catch{}});python.once('error',()=>reject(Error('Pinned QUIC receiver startup refused')));python.once('exit',()=>{clearTimeout(timer);reject(Error('Pinned QUIC receiver exited'));});});const stats=`http://127.0.0.1:${metadata.statsPort}/facts`,target=`https://127.0.0.1:${metadata.quicPort}/webtransport`;
|
||||||
page=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/direct.pac'){ordinary.pacRequests++;res.setHeader('content-type','application/x-ns-proxy-autoconfig');res.end('function FindProxyForURL(url,host) { return "DIRECT"; }');return;}if(req.method==='POST'){ordinary.posts++;req.resume();res.end('accepted');return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Native transport fixture</title><h1>Public native transport workspace</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>page.once('listening',r));const origin=`https://127.0.0.1:${page.address().port}`,env={...process.env,SAFETYNET_WT_TARGET:target,SAFETYNET_WT_HASH:metadata.hash,SAFETYNET_WT_STATS:stats,SAFETYNET_SMOKE_ORIGIN:origin};delete env.ELECTRON_RUN_AS_NODE;
|
page=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/direct.pac'){ordinary.pacRequests++;res.setHeader('content-type','application/x-ns-proxy-autoconfig');res.end('function FindProxyForURL(url,host) { return "DIRECT"; }');return;}if(req.method==='POST'){ordinary.posts++;req.resume();res.end('accepted');return;}res.setHeader('content-type','text/html');res.end('<!doctype html><title>Native transport fixture</title><h1>Public native transport workspace</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>page.once('listening',r));const origin=`https://127.0.0.1:${page.address().port}`,env={...process.env,SAFETYNET_WT_TARGET:target,SAFETYNET_WT_HASH:metadata.hash,SAFETYNET_WT_STATS:stats,SAFETYNET_SMOKE_ORIGIN:origin};delete env.ELECTRON_RUN_AS_NODE;
|
||||||
const run=async(mode,extra={})=>{const output=join(root,mode+'.json');child=spawn(createRequire(import.meta.url)('electron'),['scripts/fullmain-wt-native.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...env,SAFETYNET_WT_MODE:mode,SAFETYNET_SMOKE_USERDATA:join(root,mode+'-profile'),SAFETYNET_WT_OUTPUT:output,...extra}});child.stdout.on('data',chunk=>process.stdout.write(chunk));child.stderr.on('data',chunk=>process.stderr.write(chunk));const timer=setTimeout(()=>child.kill(),120000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Full main native transport phase failed');return JSON.parse(readFileSync(output,'utf8'));};
|
const run=async(mode,extra={})=>{const output=join(root,mode+'.json');child=spawn(createRequire(import.meta.url)('electron'),['scripts/fullmain-wt-native.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...env,SAFETYNET_WT_MODE:mode,SAFETYNET_SMOKE_USERDATA:join(root,mode+'-profile'),SAFETYNET_WT_OUTPUT:output,...extra}});child.stdout.on('data',chunk=>process.stdout.write(chunk));child.stderr.on('data',chunk=>process.stderr.write(chunk));const timer=setTimeout(()=>child.kill(),120000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(exit,0,'Full main native transport phase failed');return JSON.parse(readFileSync(output,'utf8'));};
|
||||||
stage='real baseline delivery';const before=await(await fetch(stats)).json(),baseline=await run('baseline'),after=await(await fetch(stats)).json();assert.ok(after.bytes-before.bytes>=132);assert.ok(after.workerBytes-before.workerBytes>=66);assert.ok(after.datagrams>before.datagrams&&after.streams>before.streams&&after.marker);const baselineReceiverDelta=Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]]));
|
stage='real baseline delivery';const before=await(await fetch(stats)).json(),baseline=await run('baseline'),after=await(await fetch(stats)).json();assert.ok(after.bytes-before.bytes>=132);assert.ok(after.workerBytes-before.workerBytes>=66);assert.ok(after.datagrams>before.datagrams&&after.streams>before.streams&&after.marker);const baselineReceiverDelta=Object.fromEntries(['packets','connections','datagrams','streams','bytes','workerBytes'].map(key=>[key,after[key]-before[key]]));writeFileSync('artifacts/fullmain-wt-baseline-native-proof.json',JSON.stringify({baseline,baselineReceiverDelta,sourceHashFacts},null,2));
|
||||||
store=openStore(join(root,'db'));const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'),reviewer=randomBytes(32).toString('base64url'),policy=store.get('policy');policy.browser.homeUrl=origin+'/workspace';policy.browser.allowedHosts=['127.0.0.1'];policy.agents.enabled=false;policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',policy);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'fullmain-wt-fixture');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-wt-reviewer',role:'admin'}));const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
|
store=openStore(join(root,'db'));const code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'),reviewer=randomBytes(32).toString('base64url'),policy=store.get('policy');policy.browser.homeUrl=origin+'/workspace';policy.browser.allowedHosts=['127.0.0.1'];policy.agents.enabled=false;policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',policy);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'fullmain-wt-fixture');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-wt-reviewer',role:'admin'}));const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
|
||||||
stage='real enrolled main and reviewed policies';const managed=await run('managed',{SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer});assert.equal(managed.phases.length,3);assert.equal(ordinary.posts,3);assert.ok(ordinary.pacRequests>=1,'PAC was not actually retrieved');for(const file of files)assert.equal(createHash('sha256').update(readFileSync(file)).digest('hex'),sourceHashFacts[file],'Production source changed during native proof');const report={actualFullMain:true,actualPinnedHTTP3Receiver:true,aioquicVersion:metadata.aioquic,sourceHashFacts,baseline,baselineReceiverDelta,managed,ordinary,unsupportedTransportAuditGap:managed.unsupportedTransportAuditGap};writeFileSync('artifacts/fullmain-webtransport-native-proof.json',JSON.stringify(report,null,2));console.log('Full main pinned Window/Worker WebTransport proof passed');
|
stage='real enrolled main and reviewed policies';const managed=await run('managed',{SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer});assert.equal(managed.phases.length,3);assert.equal(ordinary.posts,3);assert.ok(ordinary.pacRequests>=1,'PAC was not actually retrieved');for(const file of files)assert.equal(createHash('sha256').update(readFileSync(file)).digest('hex'),sourceHashFacts[file],'Production source changed during native proof');const report={actualFullMain:true,actualPinnedHTTP3Receiver:true,aioquicVersion:metadata.aioquic,sourceHashFacts,baseline,baselineReceiverDelta,managed,ordinary,nativeLifecycleAudit:managed.nativeTransportAudit};writeFileSync('artifacts/fullmain-webtransport-native-proof.json',JSON.stringify(report,null,2));console.log('Full main pinned Window/Worker WebTransport proof passed');
|
||||||
}catch(error){writeFileSync('artifacts/fullmain-wt-harness-error.json',JSON.stringify({stage,name:error.name},null,2));console.error('Full main WT harness failed at '+stage+': '+error.name);process.exitCode=1;}finally{child?.kill();python?.kill();for(const server of [page,management])if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}store?.db.close();await telemetry?.shutdown();rmSync(root,{recursive:true,force:true});}
|
}catch(error){writeFileSync('artifacts/fullmain-wt-harness-error.json',JSON.stringify({stage,name:error.name,sourceHashFacts},null,2));console.error('Full main WT harness failed at '+stage+': '+error.name);process.exitCode=1;}finally{child?.kill();python?.kill();for(const server of [page,management])if(server){server.closeAllConnections();await new Promise(r=>server.close(r));}store?.db.close();await telemetry?.shutdown();rmSync(root,{recursive:true,force:true});}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
'use strict';
|
||||||
|
const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto');app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();let diagnostics;const moduleDiagnostics=require('../apps/browser/diagnostics.cjs'),create=moduleDiagnostics.createDiagnostics;moduleDiagnostics.createDiagnostics=(...args)=>{const value=create(...args);diagnostics=value;return value;};require('../apps/browser/main.cjs');const delay=ms=>new Promise(r=>setTimeout(r,ms));async function until(fn){for(let i=0;i<300;i++){const v=await fn();if(v)return v;await delay(100);}throw Error('Public diagnostics timeout');}
|
||||||
|
app.whenReady().then(async()=>{let page;try{const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;}),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,cb)=>{let allowed=false;try{allowed=req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint;}catch{}cb(allowed?0:-2);});const ui=async(method,value)=>{const r=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(r.ok,true);return r.data;},headers={authorization:'Bearer '+process.env.SAFETYNET_SMOKE_ADMIN,'content-type':'application/json'};await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading()&&page.getURL()===origin+'/public');const snapshots=[];
|
||||||
|
for(let i=0;i<4;i++){assert.equal(await page.executeJavaScript("fetch('/public-post',{method:'POST',body:'Public diagnostic-only activity'}).then(r=>r.ok)"),true);snapshots.push({phase:'before-publication',index:i,status:diagnostics.status()});const p=(await(await fetch(base+'/api/admin/overview',{headers})).json()).policy;p.version++;p.brand.color=i%2?'#137c66':'#245fbc';const response=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(response.status,202);const review=await response.json(),approved=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:'sn_session='+process.env.SAFETYNET_SMOKE_REVIEWER,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approved.status,200);await until(async()=>{const state=await ui('state');return state.ready&&state.policyVersion===p.version;});await until(()=>!page.isLoading());await delay(300);snapshots.push({phase:'after-publication',index:i,status:diagnostics.status()});}
|
||||||
|
await diagnostics.flush();await delay(1000);const status=diagnostics.status(),overview=await(await fetch(base+'/api/admin/overview',{headers})).json(),event=overview.events.find(e=>e.kind==='upload.authorized'&&e.metadata.traceId);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId)).json();assert.ok(trace.spans.some(s=>s.name==='browser.request'));const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync('artifacts/native-diagnostics-public-proof.json',JSON.stringify({freshProfile:true,publicOnly:true,protectedOperationsNotRepeated:true,reviewedPolicyPublications:4,snapshots,status,traceId:event.metadata.traceId,stages:trace.spans.map(({name,operation,spanId,parentSpanId,durationMs})=>({name,operation,spanId,parentSpanId,durationMs})),health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},zeroLifetimeFailures:status.lifetimeFailedExports===0},null,2));console.log('Fresh public native diagnostic measurement completed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(status.lifetimeFailedExports===0?0:2);
|
||||||
|
}catch(error){fs.writeFileSync('artifacts/native-diagnostics-public-error.json',JSON.stringify({name:error.name,status:diagnostics?.status()},null,2));if(page&&!page.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import assert from 'node:assert/strict';import {createServer} from 'node:https';import {createRequire} from 'node:module';import {mkdtempSync,readFileSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve,sep} from 'node:path';import {randomBytes} from 'node:crypto';import {spawn} from 'node:child_process';import {createApp} from '../apps/server/app.mjs';import {openStore,digest} from '../apps/server/store.mjs';import {startTelemetry} from '../apps/server/telemetry.mjs';
|
||||||
|
const endpoint='http://127.0.0.1:4318';assert.equal((await fetch(endpoint+'/health')).ok,true);process.env.OTEL_EXPORTER_OTLP_ENDPOINT=endpoint;const telemetry=startTelemetry(),temporary=mkdtempSync(join(tmpdir(),'safetynet-public-diagnostics-'));assert.ok(resolve(temporary).startsWith(resolve(tmpdir())+sep)&&temporary.split(/[\\/]/).at(-1).startsWith('safetynet-public-diagnostics-'));const store=openStore(join(temporary,'db')),adminToken=randomBytes(32).toString('base64url'),code=randomBytes(32).toString('base64url'),reviewer=randomBytes(32).toString('base64url');let posts=0;const site=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.method==='POST'){posts++;req.resume();res.end('accepted');}else{res.setHeader('content-type','text/html');res.end('<!doctype html><h1>Public diagnostic fixture</h1>');}}).listen(0,'127.0.0.1');await new Promise(r=>site.once('listening',r));const origin=`https://127.0.0.1:${site.address().port}`,p=store.get('policy');p.browser.homeUrl=origin+'/public';p.agents.enabled=false;p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'public-diagnostics');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-public-reviewer',role:'admin'}));const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true},server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`;const child=spawn(createRequire(import.meta.url)('electron'),['scripts/native-diagnostics-public.cjs'],{windowsHide:true,stdio:['ignore','pipe','pipe'],env:{...process.env,SAFETYNET_SMOKE_USERDATA:join(temporary,'profile'),SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_ORIGIN:origin,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer}});child.stdout.on('data',v=>process.stdout.write(v));child.stderr.on('data',v=>process.stderr.write(v));const deadline=setTimeout(()=>child.kill(),120000),exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline);for(const s of [site,server])s.closeAllConnections();await Promise.all([site,server].map(s=>new Promise(r=>s.close(r))));store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});assert.equal(posts,4);process.exit(exit);
|
||||||
@@ -31,3 +31,13 @@ for(const change of ['revoke','same-version-policy','output-revoke','client-abor
|
|||||||
await assert.rejects(runApprovedAI({store,config,deviceId,policy,prompt:'Public fixture prompt',signal:controller.signal}),error=>error.status===(change==='same-version-policy'?409:403)&&error.ruleId==='ai-authorization-changed');
|
await assert.rejects(runApprovedAI({store,config,deviceId,policy,prompt:'Public fixture prompt',signal:controller.signal}),error=>error.status===(change==='same-version-policy'?409:403)&&error.ruleId==='ai-authorization-changed');
|
||||||
assert.equal(deliveries,change==='output-revoke'?1:0);
|
assert.equal(deliveries,change==='output-revoke'?1:0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('caller cancellation retains fixed authorization status while native fetch is pending',async t=>{
|
||||||
|
const directory=mkdtempSync(join(tmpdir(),'safetynet-ai-abort-')),store=openStore(directory),deviceId=randomUUID(),controller=new AbortController();
|
||||||
|
store.db.prepare('INSERT INTO devices(id,name,platform,user,token_hash) VALUES(?,?,?,?,?)').run(deviceId,'Fixture','win32','Fixture',randomUUID());
|
||||||
|
let received;const incoming=new Promise(resolve=>{received=resolve;});const provider=createServer((req,_res)=>{req.resume();received();}).listen(0,'127.0.0.1');await new Promise(resolve=>provider.once('listening',resolve));
|
||||||
|
t.after(async()=>{provider.closeAllConnections();await new Promise(resolve=>provider.close(resolve));store.db.close();rmSync(directory,{recursive:true,force:true});});
|
||||||
|
const p=store.get('policy');p.ai={...p.ai,enabled:true,trustMode:'local',model:'fixture'};store.put('policy',p);
|
||||||
|
const pending=runApprovedAI({store,config:{aiBaseUrl:`http://127.0.0.1:${provider.address().port}`,aiModel:'fixture'},deviceId,policy:store.effectivePolicy(deviceId),prompt:'Public fixture',signal:controller.signal});
|
||||||
|
const rejected=assert.rejects(pending,error=>error.status===403&&error.ruleId==='ai-authorization-changed');await incoming;controller.abort();await rejected;
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {createServer} from 'node:http';
|
||||||
|
import {createRequire} from 'node:module';
|
||||||
|
const {createDiagnostics}=createRequire(import.meta.url)('../apps/browser/diagnostics.cjs');
|
||||||
|
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||||
|
async function receiver(t,handler){const server=createServer(handler).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));t.after(()=>new Promise(resolve=>{server.closeAllConnections();server.close(resolve);}));return `http://127.0.0.1:${server.address().port}`;}
|
||||||
|
const config=endpoint=>({enabled:true,mode:'debug',endpoint,debugSampleRate:1,sampleRate:1,flushIntervalMs:10000,exportTimeoutMs:250});
|
||||||
|
async function produce(d){await d.span('browser.request',{},async()=>{});await d.flush();}
|
||||||
|
test('real timeout, refusal and HTTP503 keep fixed reasons without error text',{timeout:15000},async t=>{
|
||||||
|
const secret='SYNTHETIC_EXPORT_ERROR_MUST_NOT_RETAIN';
|
||||||
|
for(const kind of ['timeout','refused','503']){let endpoint;if(kind==='refused'){const server=createServer().listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));endpoint=`http://127.0.0.1:${server.address().port}`;await new Promise(resolve=>server.close(resolve));}else endpoint=await receiver(t,(req,res)=>{req.resume();if(kind==='503'){res.writeHead(503,secret,{'retry-after':'100'});res.end(secret);}});const d=createDiagnostics();t.after(()=>d.shutdown());d.configure(config(endpoint));await produce(d);for(let i=0;i<100&&d.status().lifetimeFailedExports<2;i++)await delay(10);const status=d.status();assert.ok(status.lifetimeFailedExports>=1,kind);const reason=kind==='timeout'?'timeout':kind==='refused'?'connection-refused':'http-server';assert.ok(status.failureReasons[reason]>=1,kind);assert.ok(status.failureFacts.every(f=>f.reason===reason));assert.ok(!JSON.stringify(status).includes(secret));assert.ok(!JSON.stringify(status).includes(endpoint));assert.ok(status.failureFacts.every(f=>['traces','logs'].includes(f.signal)&&f.activeAtCompletion&&!f.retirementOverlapped));const before=status.lifetimeFailedExports;d.configure({enabled:false});assert.equal(d.status().lifetimeFailedExports,before);assert.equal(d.status().failedExports,0);}
|
||||||
|
});
|
||||||
|
test('inflight retirement cancellation remains a failure with bounded facts and generation context',{timeout:15000},async t=>{
|
||||||
|
let received=0;const endpoint=await receiver(t,(req,_res)=>{req.resume();received++;});const d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),exportTimeoutMs:5000},{policyVersion:1});await d.span('browser.request',{},async()=>{});const flush=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);d.configure(config(endpoint),{policyVersion:2});await flush;await delay(50);const status=d.status();assert.equal(status.failedExports,0);assert.equal(status.lifetimeFailedExports,2);assert.equal(status.failureReasons.cancelled,2);assert.ok(status.failureFacts.every(f=>f.generation===1&&!f.activeAtCompletion&&f.retirementOverlapped&&f.cancelSignalled));assert.deepEqual(new Set(status.failureFacts.map(f=>f.signal)),new Set(['traces','logs']));const copy=d.status();copy.failureFacts[0].reason='forged';copy.failureReasons.cancelled=0;assert.equal(d.status().failureReasons.cancelled,2);assert.equal(d.status().failureFacts[0].reason,'cancelled');d.configure({enabled:false});assert.equal(d.status().lifetimeFailedExports,2);assert.equal(received,2,'Retirement repeated a request');
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
});
|
||||||
@@ -15,6 +15,12 @@ test('browser diagnostics exports real OTLP parent/child traces and correlated s
|
|||||||
test('disable and reconfiguration discard queued generation before export',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});diagnostics.event('browser.security',{decision:'block'});diagnostics.configure({enabled:false});await diagnostics.flush();await delay(100);assert.equal(requests.length,0);let calls=0;await diagnostics.span('browser.navigation',{},async()=>{calls++;});assert.equal(calls,1);assert.equal(diagnostics.status().enabled,false);diagnostics.configure(config(endpoint),{policyVersion:9});await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});await diagnostics.flush();assert.equal(spans(requests).length,1);assert.equal(spans(requests)[0].attributes.find(a=>a.key==='safetynet.policy.version').value.intValue,9);diagnostics.configure({...config(endpoint),endpoint:'http://10.0.0.1:4318'});assert.equal(diagnostics.status().enabled,false);});
|
test('disable and reconfiguration discard queued generation before export',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});diagnostics.event('browser.security',{decision:'block'});diagnostics.configure({enabled:false});await diagnostics.flush();await delay(100);assert.equal(requests.length,0);let calls=0;await diagnostics.span('browser.navigation',{},async()=>{calls++;});assert.equal(calls,1);assert.equal(diagnostics.status().enabled,false);diagnostics.configure(config(endpoint),{policyVersion:9});await diagnostics.span('browser.navigation',{operation:'navigate'},async()=>{});await diagnostics.flush();assert.equal(spans(requests).length,1);assert.equal(spans(requests)[0].attributes.find(a=>a.key==='safetynet.policy.version').value.intValue,9);diagnostics.configure({...config(endpoint),endpoint:'http://10.0.0.1:4318'});assert.equal(diagnostics.status().enabled,false);});
|
||||||
test('diagnostics bounded queues expire old records and never repeat failed security operations',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000});await diagnostics.span('browser.request',{requestType:'xhr'},async()=>{});await delay(1100);await diagnostics.flush();assert.equal(requests.length,0,'Expired diagnostic record exported');diagnostics.configure({...config(endpoint),maxQueueSize:16});for(let i=0;i<50;i++)diagnostics.event('browser.request',{requestType:'xhr'});await diagnostics.flush();assert.ok(logs(requests).length<=50);for(const request of requests)if(request.path.endsWith('/logs'))assert.ok(logs([request]).length<=16,'Export batch exceeded bounded queue');let calls=0;diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250});assert.equal(await diagnostics.span('browser.request',{},async()=>{calls++;return 'security decision';}),'security decision');await diagnostics.flush();assert.equal(calls,1);assert.ok(diagnostics.status().failedExports>=1);});
|
test('diagnostics bounded queues expire old records and never repeat failed security operations',{timeout:10000},async t=>{const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000});await diagnostics.span('browser.request',{requestType:'xhr'},async()=>{});await delay(1100);await diagnostics.flush();assert.equal(requests.length,0,'Expired diagnostic record exported');diagnostics.configure({...config(endpoint),maxQueueSize:16});for(let i=0;i<50;i++)diagnostics.event('browser.request',{requestType:'xhr'});await diagnostics.flush();assert.ok(logs(requests).length<=50);for(const request of requests)if(request.path.endsWith('/logs'))assert.ok(logs([request]).length<=16,'Export batch exceeded bounded queue');let calls=0;diagnostics.configure({...config(endpoint),endpoint:'http://127.0.0.1:1',exportTimeoutMs:250});assert.equal(await diagnostics.span('browser.request',{},async()=>{calls++;return 'security decision';}),'security decision');await diagnostics.flush();assert.equal(calls,1);assert.ok(diagnostics.status().failedExports>=1);});
|
||||||
test('diagnostic attributes are typed and never invoke hostile getters',()=>{const input={operation:'read',sessionId:'not-a-uuid',status:'ok',secret:'never',bytes:32};Object.defineProperty(input,'prompt',{enumerable:true,get(){throw Error('getter called');}});assert.deepEqual(attributes(input),{'safetynet.operation':'read','safetynet.status':'ok','safetynet.bytes':32});});
|
test('diagnostic attributes are typed and never invoke hostile getters',()=>{const input={operation:'read',sessionId:'not-a-uuid',status:'ok',secret:'never',bytes:32};Object.defineProperty(input,'prompt',{enumerable:true,get(){throw Error('getter called');}});assert.deepEqual(attributes(input),{'safetynet.operation':'read','safetynet.status':'ok','safetynet.bytes':32});});
|
||||||
|
test('transport diagnostic phases and fixed failures contain no destination or protocol payload',()=>{
|
||||||
|
for(const reason of require('../packages/core/transport-facts.cjs').TRANSPORT_MONITOR_REASONS){
|
||||||
|
assert.deepEqual(attributes({operation:'transport.monitor-unavailable',reason,host:'private.invalid',url:'https://private.invalid/?token=secret',nativeId:'private-cdp-id'}),{'safetynet.operation':'transport.monitor-unavailable','safetynet.reason':reason});
|
||||||
|
}
|
||||||
|
assert.deepEqual(attributes({operation:'transport.created',reason:'arbitrary company secret'}),{'safetynet.operation':'transport.created'});
|
||||||
|
});
|
||||||
|
|
||||||
test('fixed diagnostic reasons export in real OTLP fault spans and correlated logs while injected reasons are discarded',{timeout:15000},async t=>{
|
test('fixed diagnostic reasons export in real OTLP fault spans and correlated logs while injected reasons are discarded',{timeout:15000},async t=>{
|
||||||
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));
|
const {requests,endpoint}=await collector(t),diagnostics=createDiagnostics();t.after(()=>diagnostics.shutdown());diagnostics.configure(config(endpoint));
|
||||||
|
|||||||
@@ -69,5 +69,13 @@ test('concurrent proposal requests are bounded and policy changes during provide
|
|||||||
const f=await fixture(t);let release;f.setGate(new Promise(r=>{release=r;}));const pending=f.propose();
|
const f=await fixture(t);let release;f.setGate(new Promise(r=>{release=r;}));const pending=f.propose();
|
||||||
for(let n=0;n<100&&f.calls()===0;n++)await new Promise(r=>setTimeout(r,10));assert.equal(f.calls(),1);
|
for(let n=0;n<100&&f.calls()===0;n++)await new Promise(r=>setTimeout(r,10));assert.equal(f.calls(),1);
|
||||||
assert.equal((await f.propose()).status,409);assert.equal(f.calls(),1);
|
assert.equal((await f.propose()).status,409);assert.equal(f.calls(),1);
|
||||||
const p=f.store.get('policy');p.version++;f.store.put('policy',p);release();assert.equal((await pending).status,409);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
const p=f.store.get('policy');p.version++;f.store.put('policy',p);const rejected=await pending;release();assert.equal(rejected.status,409);assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provider deadline returns fixed 504 before held provider output is released',async t=>{
|
||||||
|
const f=await fixture(t);let release,deadline;f.setGate(new Promise(resolve=>{release=resolve;}));
|
||||||
|
const timeout=globalThis.setTimeout;
|
||||||
|
t.mock.method(globalThis,'setTimeout',(callback,milliseconds,...args)=>{if(milliseconds===60000){deadline=callback;return timeout(()=>{},60000);}return timeout(callback,milliseconds,...args);});
|
||||||
|
const pending=f.propose();for(let n=0;n<100&&f.calls()===0;n++)await new Promise(resolve=>timeout(resolve,10));assert.equal(f.calls(),1);assert.equal(typeof deadline,'function');
|
||||||
|
deadline();const result=await pending;release();assert.equal(result.status,504);assert.equal(result.body.error,'Request failed');assert.equal(f.store.personalBookmarks(f.device.id).revision,1);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFileSync} from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
const source=readFileSync(new URL('../apps/browser/main.cjs',import.meta.url),'utf8');
|
||||||
|
const start=source.indexOf('function installTransportObserver(tab){'),end=source.indexOf('function createNativeTab(input={})',start);
|
||||||
|
assert.ok(start>=0&&end>start);
|
||||||
|
const id='00000000-0000-4000-8000-000000000001';
|
||||||
|
function fixture(auditImplementation=async()=>id){
|
||||||
|
let callbacks;const actions=[],contents={id:1,isDestroyed:()=>false},tab={id,contents,security:{interactionDocumentGeneration:1}};
|
||||||
|
const context={device:{id},policy:{version:1,expiresAt:new Date(Date.now()+60000).toISOString()},localRelay:{},ready:true,clipboardFatal:false,tabs:{fromContents:()=>tab},Object,Date,
|
||||||
|
require:specifier=>specifier.endsWith('page-clipboard-guard.cjs')?{createNativeTransportChannel:()=>({private:true})}:{createNativeTransportObserver:value=>{callbacks=value;return {install:async()=>true};}},quarantine:()=>actions.push('quarantine'),
|
||||||
|
diagnosticSpan:async(_name,_attributes,fn)=>fn(),audit:async(...args)=>{actions.push(args[0]);return auditImplementation(...args);}};
|
||||||
|
vm.runInNewContext(source.slice(start,end)+'\nthis.install=installTransportObserver;',context);
|
||||||
|
return {context,actions,install:()=>context.install(tab),callbacks:()=>callbacks,tab};
|
||||||
|
}
|
||||||
|
test('actual main quarantines unexpected establishment before asynchronous persistence, without repeating the effect',async()=>{
|
||||||
|
let release;const gate=new Promise(resolve=>{release=resolve;});const f=fixture(async(kind)=>{if(kind==='transport.established')await gate;return id;});await f.install();
|
||||||
|
const callback=f.callbacks(),owner=callback.getContext();callback.onEstablished(owner);const pending=callback.audit({transportId:id,protocol:'webtransport',phase:'established',realm:'worker',sessionId:id,connectionEstablished:true,host:'receiver.example'},owner);
|
||||||
|
assert.equal(f.context.clipboardFatal,true);assert.deepEqual(f.actions,['quarantine','transport.established']);release();await pending;
|
||||||
|
assert.deepEqual(f.actions,['quarantine','transport.established','quarantine','transport.monitor-unavailable']);
|
||||||
|
});
|
||||||
|
test('actual main retains creation policy in lifecycle metadata while current owner remains bound',async()=>{
|
||||||
|
const records=[],f=fixture(async(...args)=>{records.push(args);return id;});await f.install();const callback=f.callbacks(),owner=callback.getContext();f.context.policy={...f.context.policy,version:2};
|
||||||
|
await callback.audit({transportId:id,protocol:'webtransport',phase:'closed',realm:'window',sessionId:id,connectionEstablished:false,host:'receiver.example'},owner);
|
||||||
|
assert.equal(records.length,1);assert.equal(records[0][8].policyVersion,1);assert.equal(records[0][9].phase,'closed');assert.equal(f.context.clipboardFatal,false);
|
||||||
|
f.context.device={id:'00000000-0000-4000-8000-000000000002'};await callback.audit({phase:'created'},owner);assert.equal(records.length,1,'Old device owner gained a new audit record');
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from 'node:test';import assert from 'node:assert/strict';import {EventEmitter} from 'node:events';import {createRequire} from 'node:module';
|
||||||
|
const {createNativeTransportObserver}=createRequire(import.meta.url)('../apps/browser/native-transport-observer.cjs');
|
||||||
|
const id='00000000-0000-4000-8000-000000000001';
|
||||||
|
function fixture(options={}){const debug=new EventEmitter(),commands=[],events=[],failures=[];let attached=true,owner={deviceId:id,policyVersion:1,sessionId:id,documentGeneration:1};debug.isAttached=()=>attached;debug.attach=()=>{attached=true;};debug.detach=()=>{attached=false;debug.emit('detach',{},'target_closed');};debug.sendCommand=async(method,params,sessionId)=>{commands.push({method,params,sessionId});return {};};const channel={isHealthy:()=>attached,enableNetwork:sessionId=>debug.sendCommand('Network.enable',{maxPostDataSize:0},sessionId),autoAttachTargets:sessionId=>debug.sendCommand('Target.setAutoAttach',{autoAttach:true,waitForDebuggerOnStart:true,flatten:true},sessionId),resumeTarget:sessionId=>debug.sendCommand('Runtime.runIfWaitingForDebugger',{},sessionId),subscribeMessage:fn=>{debug.on('message',fn);return ()=>debug.removeListener('message',fn);},subscribeDetach:fn=>{debug.on('detach',fn);return ()=>debug.removeListener('detach',fn);},release:()=>{}};const observer=createNativeTransportObserver({channel,getContext:()=>owner,isCurrent:c=>c.deviceId===owner.deviceId&&c.sessionId===owner.sessionId,audit:async(facts,context)=>events.push({facts,context}),onFailure:async reason=>failures.push(reason),onEstablished:()=>{},...options});return {debug,commands,events,failures,observer,setOwner:value=>owner=value,owner:()=>owner,emit:(method,params,sessionId)=>debug.emit('message',{},method,params,sessionId)};}
|
||||||
|
test('fixed private commands emit only bounded own-ID lifecycle metadata and retain creation policy across navigation',async()=>{
|
||||||
|
const f=fixture();await f.observer.install();assert.deepEqual(f.commands.map(x=>x.method),['Network.enable','Target.setAutoAttach']);f.emit('Network.requestWillBeSent',{request:{url:'https://private.example/?secret=private',postData:'private body'}});
|
||||||
|
f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/path?token=private',initiator:{stack:'private'}});f.setOwner({...f.owner(),policyVersion:2,documentGeneration:2});f.emit('Network.webTransportClosed',{transportId:'1.1'});await f.observer.flush();assert.equal(f.events.length,2);assert.equal(f.events[0].facts.transportId,f.events[1].facts.transportId);assert.match(f.events[0].facts.transportId,/^[a-f0-9-]{36}$/);assert.equal(f.events[1].context.policyVersion,1);assert.equal(f.events[1].context.documentGeneration,1);assert.equal(f.events[1].facts.connectionEstablished,false);assert.equal(f.events[0].facts.host,'receiver.example');assert.equal(JSON.stringify(f.events).includes('private'),false);assert.equal(JSON.stringify(f.events).includes('1.1'),false);f.observer.dispose();assert.equal(f.failures.length,0);assert.equal(f.debug.listenerCount('message'),0);assert.equal(f.debug.isAttached(),true,'Observer must preserve clipboard-owned debugger');
|
||||||
|
});
|
||||||
|
test('fresh workers queue observation and recursive attachment before startup resume',async()=>{
|
||||||
|
const f=fixture();await f.observer.install();f.emit('Target.attachedToTarget',{sessionId:'worker-1',targetInfo:{type:'worker',url:'blob:https://private.example/private'}});await f.observer.flush();assert.deepEqual(f.commands.slice(2).map(x=>x.method),['Network.enable','Target.setAutoAttach','Runtime.runIfWaitingForDebugger']);f.emit('Network.webTransportCreated',{transportId:'2.1',url:'https://receiver.example/'},'worker-1');f.emit('Network.webTransportConnectionEstablished',{transportId:'2.1'},'worker-1');f.emit('Network.webTransportClosed',{transportId:'2.1'},'worker-1');await f.observer.flush();assert.deepEqual(f.events.map(x=>x.facts.phase),['created','established','closed']);assert.ok(f.events.every(x=>x.facts.realm==='worker'));assert.equal(f.events[1].facts.connectionEstablished,true);assert.equal(f.events[2].facts.connectionEstablished,true);f.observer.dispose();
|
||||||
|
});
|
||||||
|
test('unknown session, overflow, unexpected detach and audit outage fail once without releasing protocol access',async()=>{
|
||||||
|
for(const type of ['unknown','overflow','detach','audit']){const f=fixture(type==='overflow'?{maxAttempts:1}:type==='audit'?{audit:async()=>{throw Error('private payload');}}:{});await f.observer.install();if(type==='unknown')f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/'},'unknown-session');else if(type==='detach')f.debug.detach();else{f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/'});if(type==='overflow')f.emit('Network.webTransportCreated',{transportId:'1.2',url:'https://receiver.example/'});}await f.observer.flush();assert.equal(f.observer.status().failed,true,type);const expected={unknown:'transport-observer-unavailable',overflow:'transport-observer-overflow',detach:'transport-observer-detached',audit:'transport-observer-audit-failed'};assert.deepEqual(f.failures,[expected[type]]);if(type==='overflow')assert.equal(f.events.length,1,'Previously captured attempt was erased by overflow');assert.equal(JSON.stringify(f.failures).includes('private payload'),false);f.observer.dispose();}
|
||||||
|
});
|
||||||
|
test('command timeout and unhealthy private channel never silently replaces clipboard ownership',async()=>{
|
||||||
|
const f=fixture({commandTimeoutMs:50});f.debug.sendCommand=()=>new Promise(()=>{});await assert.rejects(f.observer.install());assert.equal(f.observer.status().failed,true);f.observer.dispose();const g=fixture();g.debug.detach();await assert.rejects(g.observer.install());assert.equal(g.commands.length,0);g.observer.dispose();assert.equal(g.debug.isAttached(),false);
|
||||||
|
});
|
||||||
|
test('invalid URL metadata and changed device cannot enqueue a successful observed attempt',async()=>{
|
||||||
|
const f=fixture();await f.observer.install();f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://user:[email protected]/'});await f.observer.flush();assert.equal(f.events.length,0);assert.equal(f.observer.status().failed,true);f.observer.dispose();const g=fixture({isCurrent:()=>false});await g.observer.install();g.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/'});await g.observer.flush();assert.equal(g.events.length,0);g.observer.dispose();
|
||||||
|
});
|
||||||
|
test('IPv6 remains canonical and worker late close preserves captured lifecycle after target detach',async()=>{
|
||||||
|
const f=fixture();await f.observer.install();f.emit('Target.attachedToTarget',{sessionId:'worker-1',targetInfo:{type:'worker'}});await f.observer.flush();f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://[::1]:443/private?secret=never-log'},'worker-1');f.emit('Target.detachedFromTarget',{sessionId:'worker-1'});f.emit('Network.webTransportClosed',{transportId:'1.1'},'worker-1');await f.observer.flush();assert.equal(f.events.length,2);assert.equal(f.events[0].facts.host,'[::1]');assert.equal(f.events[1].facts.transportId,f.events[0].facts.transportId);assert.equal(f.failures.length,0);f.observer.dispose();
|
||||||
|
});
|
||||||
|
test('captured trusted audit drains after intentional teardown instead of erasing authorized event history',async()=>{
|
||||||
|
const f=fixture();await f.observer.install();f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/'});f.observer.dispose();await f.observer.flush();assert.equal(f.events.length,1);assert.equal(f.events[0].facts.phase,'created');assert.equal(f.failures.length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observer requires a private fixed channel and cannot consume arbitrary debugger authority',()=>{assert.throws(()=>createNativeTransportObserver({contents:{debugger:new EventEmitter()},getContext:()=>null,isCurrent:()=>true,audit:async()=>{},onFailure:()=>{}}),TypeError);});
|
||||||
|
|
||||||
|
test('establishment containment is synchronous even while earlier Created persistence is held',async()=>{
|
||||||
|
let release;const gate=new Promise(resolve=>{release=resolve;});const phases=[],alarms=[];
|
||||||
|
const f=fixture({audit:async facts=>{phases.push(facts.phase);if(facts.phase==='created')await gate;},onEstablished:context=>alarms.push(context)});await f.observer.install();
|
||||||
|
f.emit('Network.webTransportCreated',{transportId:'1.1',url:'https://receiver.example/'});await Promise.resolve();
|
||||||
|
f.emit('Network.webTransportConnectionEstablished',{transportId:'1.1'});assert.equal(alarms.length,1);assert.deepEqual(phases,['created']);
|
||||||
|
f.emit('Network.webTransportClosed',{transportId:'1.1'});release();await f.observer.flush();assert.deepEqual(phases,['created','established','closed']);assert.equal(alarms.length,1);f.observer.dispose();
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import {EventEmitter} from 'node:events';
|
|||||||
import {createRequire} from 'node:module';
|
import {createRequire} from 'node:module';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
const require=createRequire(import.meta.url);
|
const require=createRequire(import.meta.url);
|
||||||
const {attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard}=require('../apps/browser/page-clipboard-guard.cjs');
|
const {attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard,createNativeTransportChannel}=require('../apps/browser/page-clipboard-guard.cjs');
|
||||||
const {capturePNG}=require('../apps/browser/visual-capture.cjs');
|
const {capturePNG}=require('../apps/browser/visual-capture.cjs');
|
||||||
function fixture(valid=true){
|
function fixture(valid=true){
|
||||||
const api=new EventEmitter(),commands=[];let attached=false,detaches=0,failures=0;
|
const api=new EventEmitter(),commands=[];let attached=false,detaches=0,failures=0;
|
||||||
@@ -15,6 +15,22 @@ function fixture(valid=true){
|
|||||||
const contents={debugger:api,isDestroyed:()=>false};
|
const contents={debugger:api,isDestroyed:()=>false};
|
||||||
return {api,contents,commands,png,detachCount:()=>detaches,failureCount:()=>failures,initialize:()=>attachPageClipboardGuard(contents,()=>failures++)};
|
return {api,contents,commands,png,detachCount:()=>detaches,failureCount:()=>failures,initialize:()=>attachPageClipboardGuard(contents,()=>failures++)};
|
||||||
}
|
}
|
||||||
|
test('private transport lease exposes only fixed commands and releases waiting targets without detaching clipboard protection',async()=>{
|
||||||
|
const f=fixture();await f.initialize();const channel=createNativeTransportChannel(f.contents),messages=[];
|
||||||
|
assert.equal(channel.attach,undefined);assert.equal(channel.detach,undefined);assert.equal(channel.sendCommand,undefined);
|
||||||
|
assert.throws(()=>createNativeTransportChannel(f.contents));channel.subscribeMessage((_event,method)=>messages.push(method));
|
||||||
|
f.api.emit('message',{},'Network.requestWillBeSent',{request:{url:'https://private.invalid/?token=secret',postData:'secret'}});
|
||||||
|
f.api.emit('message',{},'Target.attachedToTarget',{sessionId:'worker-1',targetInfo:{type:'worker'}});
|
||||||
|
assert.deepEqual(messages,['Target.attachedToTarget']);await channel.enableNetwork('worker-1');await channel.autoAttachTargets('worker-1');await channel.resumeTarget('worker-1');
|
||||||
|
assert.deepEqual(f.commands.slice(2).map(c=>c.method),['Network.enable','Target.setAutoAttach','Runtime.runIfWaitingForDebugger']);
|
||||||
|
const before=f.commands.length;await assert.rejects(channel.resumeTarget('private;command'));assert.equal(f.commands.length,before);
|
||||||
|
channel.release();await new Promise(resolve=>setImmediate(resolve));assert.equal(channel.isHealthy(),false);assert.equal(f.detachCount(),0);assert.equal(f.api.isAttached(),true);
|
||||||
|
assert.equal(f.commands.at(-1).parameters.autoAttach,false);assert.ok(f.commands.slice(before).some(c=>c.method==='Runtime.runIfWaitingForDebugger'));
|
||||||
|
assert.deepEqual(await capturePNG(f.contents),f.png);await assert.rejects(channel.enableNetwork());disposePageClipboardGuard(f.contents);assert.equal(f.failureCount(),0);
|
||||||
|
});
|
||||||
|
test('transport cannot borrow an unguarded or revoked native debugger',async()=>{
|
||||||
|
const f=fixture();assert.throws(()=>createNativeTransportChannel(f.contents));await f.initialize();const channel=createNativeTransportChannel(f.contents);let detach=0;channel.subscribeDetach(()=>detach++);f.api.detach();assert.equal(detach,1);assert.equal(channel.isHealthy(),false);await assert.rejects(channel.enableNetwork());channel.release();assert.equal(f.failureCount(),1);
|
||||||
|
});
|
||||||
test('static page guard registers only fixed initialization and shares screenshot without detaching',async()=>{
|
test('static page guard registers only fixed initialization and shares screenshot without detaching',async()=>{
|
||||||
const f=fixture();await f.initialize();assert.deepEqual(f.commands.map(c=>c.method),['Page.enable','Page.addScriptToEvaluateOnNewDocument']);
|
const f=fixture();await f.initialize();assert.deepEqual(f.commands.map(c=>c.method),['Page.enable','Page.addScriptToEvaluateOnNewDocument']);
|
||||||
assert.equal(/require\(|ipcRenderer|process\./.test(f.commands[1].parameters.source),false);
|
assert.equal(/require\(|ipcRenderer|process\./.test(f.commands[1].parameters.source),false);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {mkdtempSync,rmSync} from 'node:fs';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {randomUUID} from 'node:crypto';
|
||||||
|
import {openStore,digest} from '../apps/server/store.mjs';
|
||||||
|
import {createApp} from '../apps/server/app.mjs';
|
||||||
|
import {toECS,toCEF} from '../packages/core/siem.mjs';
|
||||||
|
import transportAudit from '../packages/core/transport-facts.cjs';
|
||||||
|
const facts=phase=>({transportId:randomUUID(),protocol:'webtransport',phase,realm:'worker',sessionId:randomUUID(),connectionEstablished:phase==='established'});
|
||||||
|
test('native transport ingestion retains only strict matching lifecycle facts',async t=>{
|
||||||
|
const dir=mkdtempSync(join(tmpdir(),'safetynet-transport-audit-')),store=openStore(dir),credential='synthetic-device-transport-audit-token';
|
||||||
|
store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('device','Synthetic',digest(credential));
|
||||||
|
const server=createApp(store,{publicUrl:'http://127.0.0.1',adminToken:'synthetic-administrator-token-123456789',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
||||||
|
t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();assert.ok(dir.startsWith(join(tmpdir(),'safetynet-transport-audit-')));rmSync(dir,{recursive:true,force:true});});
|
||||||
|
const send=body=>fetch(`http://127.0.0.1:${server.address().port}/api/device/events`,{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify({eventId:randomUUID(),policyVersion:1,...body})});
|
||||||
|
for(const phase of ['created','established','closed']){const transport=facts(phase);assert.equal((await send({kind:`transport.${phase}`,host:'synthetic.example',transport})).status,200);const row=store.db.prepare('SELECT * FROM events WHERE kind=?').get(`transport.${phase}`);assert.deepEqual(JSON.parse(row.metadata).transport,transport);const ecs=toECS({...row,metadata:JSON.parse(row.metadata)});assert.deepEqual(ecs.event.type,['info']);assert.equal(ecs.event.outcome,'unknown');assert.equal(ecs.destination.domain,'synthetic.example');}
|
||||||
|
for(const body of [{kind:'transport.created'},{kind:'transport.created',transport:facts('established')},{kind:'transport.created',transport:{...facts('created'),connectionEstablished:true}},{kind:'transport.closed',transport:{...facts('closed'),url:'PRIVATE_BODY'}},{kind:'transport.created',transport:{...facts('created'),transportId:'raw-cdp-id'}},{kind:'clipboard.copy',transport:facts('created')},{kind:'transport.closed',transport:facts('closed'),request:{bytes:1,detectedType:'text'}},{kind:'transport.monitor-unavailable',ruleId:'PRIVATE_BODY'},{kind:'transport.monitor-unavailable',ruleId:'transport-observer-unavailable',transport:facts('closed')}])assert.equal((await send(body)).status,400);
|
||||||
|
for(const host of ['synthetic.example','127.0.0.1','[2001:db8::1]'])assert.equal((await send({kind:'transport.created',host,transport:facts('created')})).status,200);
|
||||||
|
for(const host of [undefined,'https://synthetic.example/private?secret=1','synthetic.example\r\nPRIVATE_BODY','user:[email protected]','synthetic.example:443','synthetic.example/private','synthetic.example?secret=1','Synthetic.Example','127.1','[2001:0db8::1]'])assert.equal((await send({kind:'transport.created',host,transport:facts('created')})).status,400);
|
||||||
|
assert.equal((await send({kind:'transport.monitor-unavailable',host:'synthetic.example',ruleId:'transport-observer-unavailable'})).status,400);
|
||||||
|
for(const ruleId of transportAudit.TRANSPORT_MONITOR_REASONS)assert.equal((await send({kind:'transport.monitor-unavailable',ruleId})).status,200);
|
||||||
|
assert.equal(store.auditIntegrity.verify().valid,true);
|
||||||
|
});
|
||||||
|
test('SIEM native lifecycle is informational and rejects injected or cross-kind facts',()=>{
|
||||||
|
const transport=facts('established'),event={id:1,time:new Date().toISOString(),kind:'transport.established',metadata:{host:'synthetic.example',transport,decision:'block',traceId:'1'.repeat(32)},audit:{hash:'2'.repeat(64),sequence:1}};
|
||||||
|
const ecs=toECS(event),cef=toCEF(event);assert.equal(ecs.event.outcome,'unknown');assert.deepEqual(ecs.event.type,['info']);assert.equal(ecs.safetynet.decision,null);assert.equal(ecs.safetynet.transport.connection_established,true);assert.equal(ecs.trace.id,'1'.repeat(32));assert.match(cef,/TransportPhase cs3=established/);assert.match(cef,/\|3\|/);assert.match(cef,/AuditRecordHash/);assert.ok(cef.includes('cs1Label=TransportId cs1='+transport.transportId));assert.equal(cef.includes('flexString3'),false);
|
||||||
|
for(const bad of [{...transport,url:'PRIVATE_BODY'},{...transport,phase:'closed'},{...transport,sessionId:'PRIVATE_BODY'}]){event.metadata.transport=bad;assert.equal(toECS(event).safetynet.transport,undefined);assert.equal(toCEF(event).includes('PRIVATE_BODY'),false);}
|
||||||
|
event.metadata.transport=transport;event.metadata.host='https://user:[email protected]/private';assert.equal(toECS(event).destination,undefined);assert.equal(toCEF(event).includes('PRIVATE_BODY'),false);event.metadata.host='synthetic.example';event.kind='upload.authorized';assert.equal(toECS(event).safetynet.transport,undefined);assert.equal(toCEF(event).includes('TransportPhase'),false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user