@@ -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