import { ipToSubnet } from './reconRisk'; import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../types/recon'; export type DiscoverySort = 'last_seen_desc' | 'last_seen_asc' | 'ip_asc'; export interface DiscoveryFilters { subnet: string; port: string; sort: DiscoverySort; } export const DEFAULT_DISCOVERY_FILTERS: DiscoveryFilters = { subnet: '', port: '', sort: 'last_seen_desc', }; /** Parse dashboard WS `subnet_discovery_update` — row payload or batch wrapper. */ export function parseSubnetDiscoveryUpdate(payload: unknown): DiscoveredHost[] { if (!payload || typeof payload !== 'object') return []; const raw = payload as Record; if (Array.isArray(raw.hosts)) { return raw.hosts.map(normalizeDiscoveredHost).filter(Boolean) as DiscoveredHost[]; } if (raw.ip || raw.host) { const one = normalizeDiscoveredHost(raw); return one ? [one] : []; } if (raw.host && typeof raw.host === 'object') { const one = normalizeDiscoveredHost(raw.host); return one ? [one] : []; } return []; } function normalizeDiscoveredHost(raw: unknown): DiscoveredHost | null { if (!raw || typeof raw !== 'object') return null; const h = raw as Record; const ip = String(h.ip ?? h.host ?? '').trim(); if (!ip) return null; const ports = normalizePorts(h.open_ports ?? h.ports); const subnet = String(h.subnet_prefix ?? h.subnet ?? ipToSubnet(ip)).trim() || ipToSubnet(ip); const lastSeen = String(h.last_seen ?? h.scanned_at ?? new Date().toISOString()).trim(); return { ip, open_ports: ports, reporter_agent_id: h.reporter_agent_id ? String(h.reporter_agent_id) : undefined, subnet_prefix: subnet, http_title: h.http_title ? String(h.http_title) : undefined, first_seen: h.first_seen ? String(h.first_seen) : undefined, last_seen: lastSeen, status: normalizeStatus(h.status), source: 'agent', }; } function normalizePorts(raw: unknown): number[] { if (!Array.isArray(raw)) return []; return [...new Set(raw.map((p) => Number(p)).filter((n) => Number.isFinite(n) && n > 0))].sort( (a, b) => a - b, ); } function normalizeStatus(raw: unknown): DiscoveredHost['status'] { const s = String(raw ?? 'uninfected').toLowerCase(); if (s === 'spread_attempted' || s === 'agent_online') return s; return 'uninfected'; } export function manualScanToDiscovery(report: ReconScanReport): DiscoveredHost { return { ip: report.host.trim(), open_ports: report.ports.filter((p) => p.open).map((p) => p.port), subnet_prefix: ipToSubnet(report.host) || report.host, http_title: report.crawl?.pages?.[0]?.title, first_seen: report.scanned_at, last_seen: report.scanned_at, status: 'uninfected', source: 'manual', }; } export function mergeDiscoveryRows( agentHosts: DiscoveredHost[], manualHosts: DiscoveredHost[], ): DiscoveredHostRow[] { const map = new Map(); for (const h of agentHosts) { map.set(h.ip, { ...h, source: 'agent' }); } for (const m of manualHosts) { const prev = map.get(m.ip); if (!prev) { map.set(m.ip, { ...m, source: 'manual' }); continue; } map.set(m.ip, { ...prev, open_ports: [...new Set([...prev.open_ports, ...m.open_ports])].sort((a, b) => a - b), last_seen: new Date(m.last_seen).getTime() >= new Date(prev.last_seen).getTime() ? m.last_seen : prev.last_seen, http_title: m.http_title || prev.http_title, source: prev.source === 'agent' ? 'agent' : 'manual', }); } return [...map.values()]; } export function filterDiscoveryRows( rows: DiscoveredHostRow[], filters: DiscoveryFilters, ): DiscoveredHostRow[] { let out = rows.filter((r) => r.status === 'uninfected'); const subnet = filters.subnet.trim(); if (subnet) { const needle = subnet.toLowerCase(); out = out.filter( (r) => r.subnet_prefix.toLowerCase().includes(needle) || r.ip.toLowerCase().startsWith(needle.replace('.x', '')), ); } const port = parseInt(filters.port, 10); if (Number.isFinite(port) && port > 0) { out = out.filter((r) => r.open_ports.includes(port)); } return sortDiscoveryRows(out, filters.sort); } export function sortDiscoveryRows( rows: DiscoveredHostRow[], sort: DiscoverySort, ): DiscoveredHostRow[] { const copy = [...rows]; if (sort === 'ip_asc') { copy.sort((a, b) => a.ip.localeCompare(b.ip, undefined, { numeric: true })); return copy; } copy.sort((a, b) => { const ta = new Date(a.last_seen).getTime(); const tb = new Date(b.last_seen).getTime(); return sort === 'last_seen_asc' ? ta - tb : tb - ta; }); return copy; } export function discoveryStatusLabel(row: DiscoveredHostRow): string { if (row.status === 'agent_online') return 'In fleet'; if (row.status === 'spread_attempted') return 'Spread attempted'; return 'Not in fleet'; } export function discoverySubnetOptions(rows: DiscoveredHostRow[]): string[] { const set = new Set(); for (const r of rows) { if (r.subnet_prefix) set.add(r.subnet_prefix); } return [...set].sort(); } export function fmtDiscoveryTime(ts?: string): string { if (!ts) return '—'; const d = new Date(ts); if (Number.isNaN(d.getTime())) return ts; return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }