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 = { '/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); } }