import { getRpcBaseUrl } from '@/services/rpc-client'; import type { CyberThreat as ProtoCyberThreat, ListCyberThreatsResponse } from '@/generated/client/worldmonitor/cyber/v1/service_client'; import type { CyberThreat, CyberThreatType, CyberThreatSource, CyberThreatSeverity, CyberThreatIndicatorType, } from '@/types'; import { createCircuitBreaker } from '@/utils'; import { ensureHydrated } from '@/services/bootstrap'; import { CyberServiceClient } from '@/services/generated-rpc-clients'; // ---- Client + Circuit Breaker ---- const client = new CyberServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); const breaker = createCircuitBreaker({ name: 'Cyber Threats', cacheTtlMs: 10 * 60 * 1000, persistCache: true }); const emptyFallback: ListCyberThreatsResponse = { threats: [], pagination: undefined }; // ---- Proto enum -> legacy string adapters ---- const THREAT_TYPE_REVERSE: Record = { CYBER_THREAT_TYPE_C2_SERVER: 'c2_server', CYBER_THREAT_TYPE_MALWARE_HOST: 'malware_host', CYBER_THREAT_TYPE_PHISHING: 'phishing', CYBER_THREAT_TYPE_MALICIOUS_URL: 'malicious_url', }; const SOURCE_REVERSE: Record = { CYBER_THREAT_SOURCE_FEODO: 'feodo', CYBER_THREAT_SOURCE_URLHAUS: 'urlhaus', CYBER_THREAT_SOURCE_C2INTEL: 'c2intel', CYBER_THREAT_SOURCE_OTX: 'otx', CYBER_THREAT_SOURCE_ABUSEIPDB: 'abuseipdb', }; const INDICATOR_TYPE_REVERSE: Record = { CYBER_THREAT_INDICATOR_TYPE_IP: 'ip', CYBER_THREAT_INDICATOR_TYPE_DOMAIN: 'domain', CYBER_THREAT_INDICATOR_TYPE_URL: 'url', }; const SEVERITY_REVERSE: Record = { CRITICALITY_LEVEL_LOW: 'low', CRITICALITY_LEVEL_MEDIUM: 'medium', CRITICALITY_LEVEL_HIGH: 'high', CRITICALITY_LEVEL_CRITICAL: 'critical', }; // ---- Adapter: proto CyberThreat -> legacy CyberThreat ---- function toCyberThreat(proto: ProtoCyberThreat): CyberThreat { return { id: proto.id, type: THREAT_TYPE_REVERSE[proto.type] || 'malicious_url', source: SOURCE_REVERSE[proto.source] || 'feodo', indicator: proto.indicator, indicatorType: INDICATOR_TYPE_REVERSE[proto.indicatorType] || 'ip', lat: proto.location?.latitude ?? 0, lon: proto.location?.longitude ?? 0, country: proto.country || undefined, severity: SEVERITY_REVERSE[proto.severity] || 'low', malwareFamily: proto.malwareFamily || undefined, tags: proto.tags, firstSeen: proto.firstSeenAt ? new Date(proto.firstSeenAt).toISOString() : undefined, lastSeen: proto.lastSeenAt ? new Date(proto.lastSeenAt).toISOString() : undefined, }; } // ---- Exported Functions ---- const DEFAULT_LIMIT = 500; const MAX_LIMIT = 1000; const DEFAULT_DAYS = 14; const MAX_DAYS = 90; function clampInt(rawValue: number | undefined, fallback: number, min: number, max: number): number { if (!Number.isFinite(rawValue)) return fallback; return Math.max(min, Math.min(max, Math.floor(rawValue as number))); } export async function fetchCyberThreats(options: { limit?: number; days?: number } = {}): Promise { // `cyberThreats` is an on-demand bootstrap key (#5300): it no longer rides in // the slow tier, because loadCyberThreats is gated on the cyber layer being ON // and that layer is off by default in every variant — so the tier was shipping // 364 KB to every visitor for data the default visitor never read. Callers that // reach here have already passed that gate, so fetch it now, through its own // CDN-shielded per-key URL. Falls through to the RPC below if that fetch fails. const hydrated = (await ensureHydrated('cyberThreats')) as { threats?: ProtoCyberThreat[] } | undefined; if (hydrated?.threats?.length) return hydrated.threats.map(toCyberThreat); const limit = clampInt(options.limit, DEFAULT_LIMIT, 1, MAX_LIMIT); const days = clampInt(options.days, DEFAULT_DAYS, 1, MAX_DAYS); const now = Date.now(); const resp = await breaker.execute(async () => { return client.listCyberThreats({ start: now - days * 24 * 60 * 60 * 1000, end: now, pageSize: limit, cursor: '', type: 'CYBER_THREAT_TYPE_UNSPECIFIED', source: 'CYBER_THREAT_SOURCE_UNSPECIFIED', minSeverity: 'CRITICALITY_LEVEL_UNSPECIFIED', }); }, emptyFallback); return resp.threats.map(toCyberThreat); }