File size: 4,415 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
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<ListCyberThreatsResponse>({ 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<string, CyberThreatType> = {
  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<string, CyberThreatSource> = {
  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<string, CyberThreatIndicatorType> = {
  CYBER_THREAT_INDICATOR_TYPE_IP: 'ip',
  CYBER_THREAT_INDICATOR_TYPE_DOMAIN: 'domain',
  CYBER_THREAT_INDICATOR_TYPE_URL: 'url',
};

const SEVERITY_REVERSE: Record<string, CyberThreatSeverity> = {
  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<CyberThreat[]> {
  // `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);
}