Stabilize diagnostic export and preserve authorized policy continuity

This commit is contained in:
Karl committed 2026-10-14 01:18:23 +02:00
1 parent 0a5df7398e
commit 09bf616d98
18 files changed
+364 -57

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import {spawnSync} from 'node:child_process';
// Do not reapply ownership to an already private directory: Windows may require
// restore privileges for SetOwner even when the current account already owns it.
const command=`$ErrorActionPreference='Stop';
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;
function Test-PrivateAcl($acl){
if(-not $acl.AreAccessRulesProtected -or $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value){return $false;}
$rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]));
if($rules.Count -ne 1){return $false;}
$rule=$rules[0];
return (-not $rule.IsInherited -and $rule.IdentityReference.Value -eq $sid.Value -and $rule.AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and $rule.FileSystemRights -eq [System.Security.AccessControl.FileSystemRights]::FullControl -and $rule.InheritanceFlags -eq ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit) -and $rule.PropagationFlags -eq [System.Security.AccessControl.PropagationFlags]::None);
}
$existing=Get-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY;
$changed=$false;
if(-not (Test-PrivateAcl $existing)){
$private=New-Object System.Security.AccessControl.DirectorySecurity;
$private.SetOwner($sid);$private.SetAccessRuleProtection($true,$false);
$private.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')));
Set-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY -AclObject $private;
$changed=$true;
}
$verified=Get-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY;
if(-not (Test-PrivateAcl $verified)){throw 'Collector directory protection verification failed';}
@{verified=$true;changed=$changed}|ConvertTo-Json -Compress;`;
export function protectCollectorDirectory(directory,{run=spawnSync}={}){
const result=run('powershell.exe',['-NoProfile','-NonInteractive','-Command',command],{windowsHide:true,timeout:10000,env:{SystemRoot:process.env.SystemRoot,SAFETYNET_COLLECTOR_DIRECTORY:directory},stdio:'pipe',encoding:'utf8'});
if(result.status!==0)throw Error('Collector directory protection failed');
let proof;try{proof=JSON.parse(result.stdout);}catch{throw Error('Collector directory protection verification failed');}
if(proof?.verified!==true||typeof proof.changed!=='boolean')throw Error('Collector directory protection verification failed');
return Object.freeze({verified:true,changed:proof.changed});
}
+17
View File
@@ -0,0 +1,17 @@
import {performance} from 'node:perf_hooks';
// Bounds unfinished HTTP bodies and committed-work admission together. This is
// diagnostic telemetry, separate from the durable security-event pipeline.
export function collectorAdmission({maxRequests=8,maxBufferedBytes=8*1024*1024,maxRequestBytes=2*1024*1024,clock=()=>performance.now(),defer=setImmediate}={}){
if([maxRequests,maxBufferedBytes,maxRequestBytes].some(value=>!Number.isSafeInteger(value)||value<1)||maxRequests>64||maxBufferedBytes>64*1024*1024||maxRequestBytes>2*1024*1024||typeof clock!=='function'||typeof defer!=='function')throw TypeError('Invalid collector admission limits');
let active=0,buffered=0,peakRequests=0,peakBufferedBytes=0,running=false;const queue=[];
const counters={requestLimitRejected:0,bufferLimitRejected:0,bodyLimitRejected:0,commits:0,commitFailures:0,lastQueueWaitMs:0,maxQueueWaitMs:0,lastCommitMs:0,maxCommitMs:0};
const duration=start=>Math.max(0,clock()-start);
function drain(){if(running||!queue.length)return;running=true;defer(async()=>{const job=queue.shift();if(!job){running=false;drain();return;}try{if(!job.live())throw Error('Collector request cancelled');const wait=duration(job.readyAt);counters.lastQueueWaitMs=wait;counters.maxQueueWaitMs=Math.max(counters.maxQueueWaitMs,wait);const start=clock();try{const result=await job.fn();counters.commits++;job.resolve(result);}catch(error){counters.commitFailures++;job.reject(error);}finally{const elapsed=duration(start);counters.lastCommitMs=elapsed;counters.maxCommitMs=Math.max(counters.maxCommitMs,elapsed);}}catch(error){job.reject(error);}finally{running=false;drain();}});}
function acquire(){if(active>=maxRequests){counters.requestLimitRejected++;return null;}active++;peakRequests=Math.max(peakRequests,active);let bytes=0,released=false,enqueued=false,queuedJob;
return {add(size){if(released||enqueued||!Number.isSafeInteger(size)||size<0)throw Error('Collector admission unavailable');if(bytes+size>maxRequestBytes){counters.bodyLimitRejected++;return 'body-limit';}if(buffered+size>maxBufferedBytes){counters.bufferLimitRejected++;return 'buffer-limit';}bytes+=size;buffered+=size;peakBufferedBytes=Math.max(peakBufferedBytes,buffered);return null;},
enqueue(fn){if(released||enqueued||typeof fn!=='function')return Promise.reject(Error('Collector admission unavailable'));enqueued=true;return new Promise((resolve,reject)=>{queuedJob={fn,resolve,reject,readyAt:clock(),live:()=>!released};queue.push(queuedJob);drain();});},
release(){if(released)return;released=true;active--;buffered-=bytes;bytes=0;if(queuedJob){const index=queue.indexOf(queuedJob);if(index>=0){queue.splice(index,1);queuedJob.reject(Error('Collector request cancelled'));}}}};
}
return {acquire,summary:()=>({maxRequests,maxBufferedBytes,maxRequestBytes,activeRequests:active,bufferedBytes:buffered,queuedCommits:queue.length,commitActive:running,peakRequests,peakBufferedBytes,...counters})};
}
+34 -15
View File
@@ -3,6 +3,9 @@ import {createServer} from 'node:http';
import {mkdirSync,existsSync,readFileSync,writeFileSync} from 'node:fs';
import {collectorStorage} from './debug-collector-storage.mjs';
import {normalizeCollectorBatch} from './debug-collector-batch.mjs';
import {collectorAdmission} from './debug-collector-admission.mjs';
import {protectCollectorDirectory} from './debug-collector-acl.mjs';
import {monitorEventLoopDelay} from 'node:perf_hooks';
import {resolve} from 'node:path';
import {spawnSync} from 'node:child_process';
import {randomBytes,createCipheriv} from 'node:crypto';
@@ -10,14 +13,16 @@ const port=Number(process.env.DEBUG_COLLECTOR_PORT||4318),directory=resolve(proc
if(process.platform!=='win32')throw Error('Local protected collector currently requires Windows DPAPI');
mkdirSync(directory,{recursive:true});
const storage=collectorStorage(directory,{segmentBytes:Number(process.env.DEBUG_COLLECTOR_SEGMENT_BYTES||50*1024*1024),retiredSegments:Number(process.env.DEBUG_COLLECTOR_RETIRED_SEGMENTS||2)});
const acl=spawnSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop';$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;$a=New-Object System.Security.AccessControl.DirectorySecurity;$a.SetOwner($sid);$a.SetAccessRuleProtection($true,$false);$a.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')));Set-Acl -LiteralPath $env:SAFETYNET_COLLECTOR_DIRECTORY -AclObject $a"],{windowsHide:true,timeout:10000,env:{SystemRoot:process.env.SystemRoot,SAFETYNET_COLLECTOR_DIRECTORY:directory},stdio:'pipe'});
if(acl.status!==0)throw Error('Collector directory protection failed');
protectCollectorDirectory(directory);
function protect(operation,value){const result=spawnSync(resolve('.runtime/windows/python/python.exe'),[resolve('apps/inspector/dpapi.py'),operation],{input:value,encoding:'utf8',windowsHide:true,timeout:5000,env:{SystemRoot:process.env.SystemRoot,USERPROFILE:process.env.USERPROFILE,LOCALAPPDATA:process.env.LOCALAPPDATA,PYTHONDONTWRITEBYTECODE:'1'}});if(result.status!==0)throw Error('Collector DPAPI key protection failed');return result.stdout.trim();}
const keyFile=resolve(directory,'collector-key.dpapi');
let key;if(existsSync(keyFile)){const value=protect('unprotect',readFileSync(keyFile,'utf8'));if(!value.startsWith('observability-v1:'))throw Error('Collector key binding rejected');key=Buffer.from(value.slice(17),'base64');}else{key=randomBytes(32);writeFileSync(keyFile,protect('protect','observability-v1:'+key.toString('base64')),{flag:'wx',mode:0o600});}
if(key.length!==32)throw Error('Invalid collector key');
const admission=collectorAdmission({maxRequests:Number(process.env.DEBUG_COLLECTOR_MAX_REQUESTS||8),maxBufferedBytes:Number(process.env.DEBUG_COLLECTOR_MAX_BUFFERED_BYTES||8*1024*1024)});
const eventLoop=monitorEventLoopDelay({resolution:20});eventLoop.enable();
const lag=()=>({meanMs:Number.isFinite(eventLoop.mean)?eventLoop.mean/1e6:0,maxMs:eventLoop.max/1e6,p99Ms:eventLoop.percentile(99)/1e6});
const counts={traces:0,logs:0,metrics:0},recent=[];let requests=0;
function summary(){return {service:'safetynet-local-debug',protectedStorage:'Windows DPAPI + AES-256-GCM',requests,counts,...storage.summary(),retainedSpans:recent.length,slowest:recent.slice().sort((a,b)=>b.durationMs-a.durationMs).slice(0,15)};}
function summary(){return {service:'safetynet-local-debug',protectedStorage:'Windows DPAPI + AES-256-GCM',requests,counts,admission:admission.summary(),eventLoopLag:lag(),...storage.summary(),retainedSpans:recent.length,slowest:recent.slice().sort((a,b)=>b.durationMs-a.durationMs).slice(0,15)};}
const server=createServer((req,res)=>{
res.setHeader('cache-control','no-store');res.setHeader('x-content-type-options','nosniff');
const origin=`http://127.0.0.1:${port}`;
@@ -25,17 +30,31 @@ const server=createServer((req,res)=>{
if(req.method==='GET'&&['/','/health','/api/summary'].includes(req.url)){res.setHeader('content-type','application/json');try{res.end(JSON.stringify(summary(),null,2));}catch{res.writeHead(503);res.end('{"error":"Protected collector storage unavailable"}');}return;}
if(req.method==='GET'&&/^\/api\/trace\/[a-f0-9]{32}$/.test(req.url)){res.setHeader('content-type','application/json');res.end(JSON.stringify({traceId:req.url.slice(11),spans:recent.filter(span=>span.traceId===req.url.slice(11))},null,2));return;}
if(req.method!=='POST'||!['/v1/traces','/v1/logs','/v1/metrics'].includes(req.url)||req.headers['content-encoding']){res.writeHead(404);res.end();return;}
const chunks=[];let size=0;req.on('data',chunk=>{size+=chunk.length;if(size>2*1024*1024)req.destroy();else chunks.push(chunk);});
req.on('end',()=>{try{
const payload=JSON.parse(Buffer.concat(chunks).toString('utf8')),signal=req.url.slice(4);if(!payload||typeof payload!=='object'||Array.isArray(payload))throw Error('Invalid OTLP');
const normalized=normalizeCollectorBatch(signal,payload);
const nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(Buffer.from('safetynet-debug-v1'));
const plain=Buffer.from(JSON.stringify({signal,receivedAt:new Date().toISOString(),payload})),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);
const line=JSON.stringify({v:1,nonce:nonce.toString('base64'),tag:cipher.getAuthTag().toString('base64'),ciphertext:encrypted.toString('base64')})+'\n';
try{storage.append(line);}catch{res.writeHead(507);res.end('{"error":"Protected collector storage unavailable"}');return;}requests++;
counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift();
res.setHeader('content-type','application/json');res.end('{}');
}catch{res.writeHead(400);res.end('{"error":"Invalid telemetry batch"}');}});
const lease=admission.acquire();
const rejectBusy=()=>{res.setHeader('connection','close');res.setHeader('retry-after','1');res.writeHead(503);res.end('{"error":"Collector busy"}');};
if(!lease){rejectBusy();return;}
const chunks=[];let finished=false;
const release=()=>{lease.release();chunks.length=0;};
const fail=(status,body)=>{if(finished)return;finished=true;release();res.setHeader('connection','close');res.writeHead(status);res.end(body);};
req.once('aborted',()=>{finished=true;release();});req.once('error',()=>{finished=true;release();});
res.once('close',()=>{if(!finished){finished=true;release();}});
req.setTimeout(10000,()=>fail(408,'{"error":"Collector request timed out"}'));
req.on('data',chunk=>{if(finished)return;const rejected=lease.add(chunk.length);if(rejected){if(rejected==='body-limit')fail(413,'{"error":"Telemetry batch too large"}');else{finished=true;release();rejectBusy();}}else chunks.push(chunk);});
req.on('end',()=>{if(finished)return;req.setTimeout(0);
void lease.enqueue(()=>{if(finished)return;
try{
const payload=JSON.parse(Buffer.concat(chunks).toString('utf8')),signal=req.url.slice(4);if(!payload||typeof payload!=='object'||Array.isArray(payload))throw Error('Invalid OTLP');
const normalized=normalizeCollectorBatch(signal,payload);
const nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(Buffer.from('safetynet-debug-v1'));
const plain=Buffer.from(JSON.stringify({signal,receivedAt:new Date().toISOString(),payload})),encrypted=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0);
const line=JSON.stringify({v:1,nonce:nonce.toString('base64'),tag:cipher.getAuthTag().toString('base64'),ciphertext:encrypted.toString('base64')})+'\n';
// HTTP success remains after the guarded encrypted append and fsync.
try{storage.append(line);}catch{fail(507,'{"error":"Protected collector storage unavailable"}');throw Error('Collector commit failed');}
requests++;counts.traces+=normalized.spans.length;counts.logs+=normalized.logs;counts.metrics+=normalized.metrics;recent.push(...normalized.spans);while(recent.length>5000)recent.shift();
finished=true;res.setHeader('content-type','application/json');res.end('{}');
}catch{fail(400,'{"error":"Invalid telemetry batch"}');throw Error('Collector batch failed');}
}).catch(()=>{if(!finished)fail(503,'{"error":"Collector busy"}');}).finally(release);
});
});
server.listen(port,'127.0.0.1',()=>console.log(`Local debug collector: http://127.0.0.1:${port}/api/summary`));
function stop(){server.closeAllConnections();server.close(()=>{key.fill(0);process.exit(0);});}process.on('SIGINT',stop);process.on('SIGTERM',stop);
function stop(){eventLoop.disable();server.closeAllConnections();server.close(()=>{key.fill(0);process.exit(0);});}process.on('SIGINT',stop);process.on('SIGTERM',stop);