From 8c343ac984d425f3411ca23d71a4123b3789e39e Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 12:28:27 -0700 Subject: [PATCH] Wire Deploy Recon page to new recon API panels and streaming results. --- .../components/Recon/DeployReconPanels.tsx | 28 +- .../web/src/components/Recon/HistoryPanel.tsx | 54 ++- server/web/src/help/deployRecon.ts | 17 +- server/web/src/pages/DeployReconPage.css | 42 ++ server/web/src/pages/DeployReconPage.test.tsx | 6 +- server/web/src/pages/DeployReconPage.tsx | 397 ++++++++++++------ 6 files changed, 375 insertions(+), 169 deletions(-) diff --git a/server/web/src/components/Recon/DeployReconPanels.tsx b/server/web/src/components/Recon/DeployReconPanels.tsx index 610bfc8..c4e5a20 100644 --- a/server/web/src/components/Recon/DeployReconPanels.tsx +++ b/server/web/src/components/Recon/DeployReconPanels.tsx @@ -244,17 +244,20 @@ export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: Adm - {rows.map((r) => ( - - - - {r.path} - - - {r.status_code} - {r.signal} - - ))} + {rows.map((r) => { + const live = r.status_code >= 200 && r.status_code < 400; + return ( + + + + {r.path} + + + {r.status_code || '—'} + {r.signal} + + ); + })} @@ -282,9 +285,11 @@ export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack: export const ActionMatrix = memo(function ActionMatrix({ rows, onOpenPlaybook, + onAction, }: { rows: ReconActionRow[]; onOpenPlaybook?: (lane: string) => void; + onAction?: (lane: string, action: string) => void; }) { if (!rows.length) return null; return ( @@ -312,6 +317,7 @@ export const ActionMatrix = memo(function ActionMatrix({ className="btn btn-outline btn-sm" onClick={() => { if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane); + onAction?.(r.lane, a); }} > {a} diff --git a/server/web/src/components/Recon/HistoryPanel.tsx b/server/web/src/components/Recon/HistoryPanel.tsx index 8584e66..cb81e5a 100644 --- a/server/web/src/components/Recon/HistoryPanel.tsx +++ b/server/web/src/components/Recon/HistoryPanel.tsx @@ -1,7 +1,7 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; import { HelpTip } from '../HelpTip'; -import { loadReconHistory, openPortsSignature } from '../../help/reconHistory'; +import { diffReconReports, loadReconHistory, openPortsSignature } from '../../help/reconHistory'; import type { ReconHistoryEntry } from '../../types/recon'; export default memo(function HistoryPanel({ @@ -34,6 +34,16 @@ export default memo(function HistoryPanel({ void refresh(); }, [refresh]); + const diffsByScanId = useMemo(() => { + const out = new Map>(); + for (let i = 0; i < entries.length; i += 1) { + const prev = entries[i + 1]; + if (!prev) continue; + out.set(entries[i].scan_id, diffReconReports(prev.report, entries[i].report)); + } + return out; + }, [entries]); + return (
@@ -49,20 +59,32 @@ export default memo(function HistoryPanel({

No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.

) : null}
    - {entries.map((e) => ( -
  • - -
  • - ))} + {entries.map((e) => { + const diffs = diffsByScanId.get(e.scan_id) ?? []; + return ( +
  • + +
  • + ); + })}
); diff --git a/server/web/src/help/deployRecon.ts b/server/web/src/help/deployRecon.ts index e9b6701..5f4146a 100644 --- a/server/web/src/help/deployRecon.ts +++ b/server/web/src/help/deployRecon.ts @@ -148,7 +148,22 @@ export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: s export type AdminSurfaceMapRow = ReconAdminSurfaceFinding; export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] { - return report.admin_surface ?? []; + const admin = report.admin_surface ?? []; + const uploads = (report.crawl?.upload_hunter ?? []).map((u) => { + let path = u.target?.trim() || u.page_url; + try { + path = u.target?.trim() || new URL(u.page_url).pathname || u.page_url; + } catch { + /* relative URL */ + } + return { + path, + url: u.page_url, + status_code: u.status_code ?? 0, + signal: u.tags?.length ? u.tags.join(' · ') : u.source || 'upload', + }; + }); + return [...admin, ...uploads]; } export function buildTechStackHints(report: ReconScanReport) { diff --git a/server/web/src/pages/DeployReconPage.css b/server/web/src/pages/DeployReconPage.css index e2f3e84..df1241d 100644 --- a/server/web/src/pages/DeployReconPage.css +++ b/server/web/src/pages/DeployReconPage.css @@ -420,6 +420,48 @@ border-bottom: 1px solid rgba(255, 255, 255, 0.04); } +.dr-admin-live td { + color: #7dffb0; +} + +.dr-admin-muted td { + color: var(--text-muted); +} + +.dr-history-diffs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.dr-history-diff { + font-family: var(--font-tech); + font-size: 0.58rem; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-muted); +} + +.dr-history-diff.port_opened { + border-color: rgba(80, 220, 120, 0.35); + color: #7dffb0; +} + +.dr-history-diff.port_closed, +.dr-history-diff.ssrf_down { + border-color: rgba(255, 120, 120, 0.3); + color: #ffb0b0; +} + +.dr-history-diff.ssrf_up, +.dr-history-diff.cms_added { + border-color: rgba(0, 232, 245, 0.35); + color: #9efcff; +} + .dr-fp-grid { display: grid; gap: 0.55rem; diff --git a/server/web/src/pages/DeployReconPage.test.tsx b/server/web/src/pages/DeployReconPage.test.tsx index 2c4816d..7a2c8ea 100644 --- a/server/web/src/pages/DeployReconPage.test.tsx +++ b/server/web/src/pages/DeployReconPage.test.tsx @@ -105,6 +105,8 @@ describe('DeployReconPage', () => { expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument(); expect(screen.getByTestId('dr-host-input')).toBeInTheDocument(); expect(screen.getByTestId('dr-scan-btn')).toBeDisabled(); + expect(screen.getByTestId('dr-scan-profile')).toBeInTheDocument(); + expect(screen.getByTestId('dr-port-bundle')).toBeInTheDocument(); }); it('runs scan and shows port matrix and findings', async () => { @@ -117,8 +119,10 @@ describe('DeployReconPage', () => { await waitFor(() => expect(api.reconScan).toHaveBeenCalled()); expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument(); expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument(); + expect(screen.getByTestId('dr-action-matrix')).toBeInTheDocument(); + expect(screen.getByTestId('dr-export-json')).toBeInTheDocument(); const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i }); - expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread'); + expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread&finding=winrm'); }); it('shows fleet discoveries table with uninfected badge', async () => { diff --git a/server/web/src/pages/DeployReconPage.tsx b/server/web/src/pages/DeployReconPage.tsx index dcbfdb4..152293a 100644 --- a/server/web/src/pages/DeployReconPage.tsx +++ b/server/web/src/pages/DeployReconPage.tsx @@ -1,39 +1,48 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../api/client'; import type { BuildRecord } from '../types'; -import type { ReconPortResult, ReconScanReport, ReconWebFindingCard } from '../types/recon'; +import type { ReconHistoryEntry, ReconPortResult, ReconScanReport } from '../types/recon'; import { HelpTip } from '../components/HelpTip'; import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader'; import { - FLEET_SPREAD_PORTS, - RECON_PORT_HINTS, + ActionMatrix, + BannerGrabPanel, + FindingCard, + FormFingerprintCards, + PortBundleSelector, + ScanProfileSelector, + SsrfCanaryPanel, + StreamingPortMatrix, + TechStackBanner, + UploadAdminMap, + useDeployReconDerived, +} from '../components/Recon/DeployReconPanels'; +import { + buildActionMatrix, + buildPlaybookTree, buildReconFindingCards, buildReconMermaid, crucibleSpreadLink, curlInstallLine, + FLEET_SPREAD_PORTS, + isLocalScanUnreachableError, reconScanBody, + streamRevealPorts, + suggestDeployKitFinding, + type PortBundleId, + type ScanProfileId, } from '../help/deployRecon'; -import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel'; +import { exportReconJson, saveReconHistoryEntry } from '../help/reconHistory'; +import { useWebSocket } from '../hooks/useWebSocket'; import './DeployReconPage.css'; import '../components/Fleet/ReconVisuals.css'; -const SCAN_DEBOUNCE_MS = 350; +const FleetDiscoveriesPanel = lazy(() => import('../components/Recon/FleetDiscoveriesPanel')); +const HistoryPanel = lazy(() => import('../components/Recon/HistoryPanel')); +const PlaybookWizardModal = lazy(() => import('../components/Recon/PlaybookWizardModal')); -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 ( - - ); -} +const SCAN_DEBOUNCE_MS = 350; function ScanSkeleton() { return ( @@ -46,90 +55,35 @@ function ScanSkeleton() { ); } -const PortMatrix = memo(function PortMatrix({ - ports, - onHint, -}: { - ports: ReconPortResult[]; - onHint: (hint: string) => void; -}) { - return ( -
-

- Port matrix -

-
- {ports.map((p) => ( - - ))} -
-
- ); -}); - -const FindingCard = memo(function FindingCard({ - card, - curlLine, - host, - fleetSpreadOpen, -}: { - card: ReconWebFindingCard; - curlLine: string; - host: string; - fleetSpreadOpen: boolean; -}) { - return ( -
-
- {card.title} - {card.confidence} - {card.spread_lane ? ( - {card.spread_lane} - ) : null} -
-

{card.detail}

- {card.mermaid ?
{card.mermaid}
: null} -
- - {card.probe_url ? ( - - ) : null} - {fleetSpreadOpen ? ( - - Fleet spread to {host} - - ) : null} -
-
- ); -}); - const ResultsPanel = memo(function ResultsPanel({ report, - cards, - mermaid, serverBase, pinnedBuildId, + deployKitLane, + streaming, + visiblePorts, + onOpenPlaybook, + onMatrixAction, + canaryWsHit, }: { report: ReconScanReport; - cards: ReconWebFindingCard[]; - mermaid: string; serverBase: string; pinnedBuildId: string; + deployKitLane: string; + streaming: boolean; + visiblePorts: ReconPortResult[]; + onOpenPlaybook: (lane: string) => void; + onMatrixAction: (lane: string, action: string) => void; + canaryWsHit?: string | null; }) { const [portHint, setPortHint] = useState(''); + const derived = useDeployReconDerived(report, serverBase); + const cards = useMemo( + () => buildReconFindingCards(report, serverBase), + [report, serverBase], + ); + const mermaid = useMemo(() => buildReconMermaid(report.ports, cards), [report.ports, cards]); + const actionRows = useMemo(() => buildActionMatrix(report), [report]); const curlLine = useMemo( () => curlInstallLine(serverBase, pinnedBuildId), [serverBase, pinnedBuildId], @@ -141,9 +95,27 @@ const ResultsPanel = memo(function ResultsPanel({ return (
- + {deployKitLane ? ( +

+ Deploy kit lane: {deployKitLane} + +

+ ) : null} + {report.relay_via ? ( +

Relayed via {report.relay_via}

+ ) : null} + + {portHint ?

{portHint}

: null} + + + + + + + +
Web findings {cards.length === 0 ? ( @@ -157,6 +129,7 @@ const ResultsPanel = memo(function ResultsPanel({ curlLine={curlLine} host={report.host} fleetSpreadOpen={fleetSpreadOpen} + deployKitLane={deployKitLane} /> ))}
@@ -170,35 +143,50 @@ const ResultsPanel = memo(function ResultsPanel({ ) : null} - {(report.recommendations?.length ?? 0) > 0 ? ( -
- Spread recommendations -
    - {report.recommendations!.map((r, i) => ( -
  • {r.reason}
  • - ))} -
-
- ) : null} +
+ + + Open Crucible spread + +
); }); export default function DeployReconPage() { + const { latestMessage } = useWebSocket(); const [host, setHost] = useState(''); const [port, setPort] = useState('80'); const [https, setHttps] = useState(false); const [pathPrefix, setPathPrefix] = useState(''); + const [scanProfile, setScanProfile] = useState('quick'); + const [portBundle, setPortBundle] = useState('fleet'); const [scanning, setScanning] = useState(false); + const [streaming, setStreaming] = useState(false); const [error, setError] = useState(''); + const [relayOffer, setRelayOffer] = useState(false); + const [relayBusy, setRelayBusy] = useState(false); const [report, setReport] = useState(null); + const [visiblePorts, setVisiblePorts] = useState([]); const [manualScans, setManualScans] = useState([]); const [deepScanIp, setDeepScanIp] = useState(''); const [serverBase, setServerBase] = useState(''); const [pinnedBuildId, setPinnedBuildId] = useState(''); + const [deployKitLane, setDeployKitLane] = useState(''); + const [playbookOpen, setPlaybookOpen] = useState(false); + const [playbookLane, setPlaybookLane] = useState(); + const [canaryWsHit, setCanaryWsHit] = useState(null); const abortRef = useRef(null); const debounceRef = useRef | null>(null); + const streamAbortRef = useRef(false); useEffect(() => { void (async () => { @@ -218,6 +206,44 @@ export default function DeployReconPage() { })(); }, []); + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'recon_canary_hit') return; + const payload = latestMessage.payload as { scan_id?: string }; + if (payload?.scan_id) setCanaryWsHit(payload.scan_id); + }, [latestMessage]); + + const revealPorts = useCallback(async (ports: ReconPortResult[]) => { + streamAbortRef.current = false; + setStreaming(true); + setVisiblePorts([]); + for await (const chunk of streamRevealPorts(ports)) { + if (streamAbortRef.current) return; + setVisiblePorts(chunk); + } + if (!streamAbortRef.current) setStreaming(false); + }, []); + + const applyReport = useCallback(async (res: ReconScanReport) => { + setReport(res); + saveReconHistoryEntry(res); + setManualScans((prev) => { + const rest = prev.filter((s) => s.host.trim() !== res.host.trim()); + return [res, ...rest].slice(0, 48); + }); + setDeployKitLane(suggestDeployKitFinding(res)); + void revealPorts(res.ports); + try { + const kit = await api.getReconDeployKit({ + host: res.host, + finding: suggestDeployKitFinding(res) || undefined, + }); + if (kit?.join_lane) setDeployKitLane(kit.join_lane); + else if (kit?.finding) setDeployKitLane(kit.finding); + } catch { + /* optional API */ + } + }, [revealPorts]); + const runScan = useCallback(async (targetHost?: string) => { const trimmed = (targetHost ?? host).trim(); if (!trimmed) { @@ -228,33 +254,54 @@ export default function DeployReconPage() { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; + setRelayOffer(false); const portNum = parseInt(port, 10) || 80; try { - const body = reconScanBody(trimmed, portNum, https, pathPrefix); + const body = reconScanBody(trimmed, portNum, https, pathPrefix, scanProfile, portBundle); const res = await api.reconScan(body, controller.signal); - 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); - }); - } + if (!controller.signal.aborted) await applyReport(res); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') return; - setError(e instanceof Error ? e.message : 'Scan failed'); + const msg = e instanceof Error ? e.message : 'Scan failed'; + setError(msg); + if (isLocalScanUnreachableError(msg)) setRelayOffer(true); } finally { if (!controller.signal.aborted) { setScanning(false); setDeepScanIp(''); } } - }, [host, port, https, pathPrefix]); + }, [host, port, https, pathPrefix, scanProfile, portBundle, applyReport]); + + const runRelayScan = useCallback(async () => { + const trimmed = host.trim(); + if (!trimmed || relayBusy) return; + setRelayBusy(true); + setError(''); + try { + const res = await api.reconRelayScan({ host: trimmed, udp_guess: scanProfile === 'deep' }); + if (res?.ok && res.report) { + await applyReport(res.report); + setRelayOffer(false); + } else { + setError(res?.error || 'Relay scan failed'); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Relay scan failed'); + } finally { + setRelayBusy(false); + setScanning(false); + } + }, [host, relayBusy, scanProfile, applyReport]); const handleScanDeeper = useCallback((ip: string) => { setHost(ip); setDeepScanIp(ip); setScanning(true); setError(''); + setReport(null); + setVisiblePorts([]); + streamAbortRef.current = true; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void runScan(ip); @@ -268,9 +315,12 @@ export default function DeployReconPage() { return; } abortRef.current?.abort(); + streamAbortRef.current = true; setScanning(true); setError(''); + setRelayOffer(false); setReport(null); + setVisiblePorts([]); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void runScan(); @@ -279,18 +329,37 @@ export default function DeployReconPage() { useEffect(() => () => { abortRef.current?.abort(); + streamAbortRef.current = true; if (debounceRef.current) clearTimeout(debounceRef.current); }, []); - const cards = useMemo( - () => (report ? buildReconFindingCards(report, serverBase) : []), - [report, serverBase], + const playbookTree = useMemo( + () => (report ? buildPlaybookTree(report) : []), + [report], ); - const mermaid = useMemo( - () => (report ? buildReconMermaid(report.ports, cards) : ''), - [report, cards], + + const curlLine = useMemo( + () => curlInstallLine(serverBase, pinnedBuildId), + [serverBase, pinnedBuildId], ); + const handleMatrixAction = useCallback((lane: string, action: string) => { + const trimmed = report?.host.trim() ?? host.trim(); + if (action === 'copy_curl') { + void navigator.clipboard?.writeText(curlLine); + } else if (action === 'copy_probe') { + const cards = report ? buildReconFindingCards(report, serverBase) : []; + const probe = cards.find((c) => c.probe_url)?.probe_url; + if (probe) void navigator.clipboard?.writeText(probe); + } else if (action === 'crucible' && trimmed) { + window.open(crucibleSpreadLink(trimmed, lane), '_blank', 'noopener,noreferrer'); + } + }, [curlLine, host, report, serverBase]); + + const handleHistorySelect = useCallback((entry: ReconHistoryEntry) => { + void applyReport(entry.report); + }, [applyReport]); + return (
+ +
- +
+ + {relayOffer ? ( + + ) : null} + +
{error ?

{error}

: null} - {scanning ? : null} + {scanning && !report ? : null} {report && !scanning ? ( { + setPlaybookLane(lane); + setPlaybookOpen(true); + }} + onMatrixAction={handleMatrixAction} + canaryWsHit={canaryWsHit} /> ) : null} - + Loading history…

}> + +
+ + + + + + + setPlaybookOpen(false)} + tree={playbookTree} + focusLane={playbookLane} + /> +
); }