Files
multi-simadmin/packages/test-fixtures/scripts/collector.ts
T

65 lines
5.7 KiB
TypeScript

import { redactOperationPayload } from '../src/redactor.ts';
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
export const UPSTREAM_BASELINE='58e2204';
export const DENY_REASONS:Record<string,string>={
'/api/network/operators/scan':'active radio/network scan',
'/api/connectivity':'handler performs active connectivity ping',
'/api/sms/list':'message list can expose body; intentionally not collected',
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
'/api/call/history':'query limit is not encoded in Registry path contract',
'/api/notifications/logs':'query limit is not encoded in Registry path contract',
'/api/notifications/queue':'query limit is not encoded in Registry path contract',
'/api/automation/logs':'query limit is not encoded in Registry path contract',
'/api/device-network/ddns/logs':'no safe limit contract',
'/api/esim/euicc':'query-bearing endpoint omitted',
'/api/esim/profiles':'query-bearing endpoint omitted',
};
const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op]));
// Capture the complete imported JSON contract before any caller can mutate it.
// Exact identity alone does not protect mutable Registry objects.
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));}
export function validateInstanceOrigin(origin:string){
let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');}
if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin');
return u.origin;
}
function validatePath(path:string){
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path');
let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;}
if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path');
return path;
}
export function validateReadonlyOperation(op:any){
const exact=registryById.get(op?.operationId);
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
validatePath(op.pathTemplate);
if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0');
if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied');
return op;
}
export function selectReadonlyOperations(registry:any[]){
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
for(const op of candidates) validatePath(op.pathTemplate);
return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))};
}
function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';}
function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';}
export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){
validateReadonlyOperation(op);
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
const origin=validateInstanceOrigin(instance?.origin);
const requestUrl=new URL(op.pathTemplate,origin);
if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL');
const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000);
const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true};
try{
validateReadonlyOperation(op);
const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal});
const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json');
let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};}
return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)};
}catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);}
}