Wire Deploy Recon page to new recon API panels and streaming results.
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
This commit is contained in:
@@ -244,17 +244,20 @@ export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: Adm
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => {
|
||||||
<tr key={r.url}>
|
const live = r.status_code >= 200 && r.status_code < 400;
|
||||||
<td>
|
return (
|
||||||
<a href={r.url} target="_blank" rel="noreferrer">
|
<tr key={r.url} className={live ? 'dr-admin-live' : 'dr-admin-muted'}>
|
||||||
{r.path}
|
<td>
|
||||||
</a>
|
<a href={r.url} target="_blank" rel="noreferrer">
|
||||||
</td>
|
{r.path}
|
||||||
<td>{r.status_code}</td>
|
</a>
|
||||||
<td>{r.signal}</td>
|
</td>
|
||||||
</tr>
|
<td>{r.status_code || '—'}</td>
|
||||||
))}
|
<td>{r.signal}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -282,9 +285,11 @@ export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack:
|
|||||||
export const ActionMatrix = memo(function ActionMatrix({
|
export const ActionMatrix = memo(function ActionMatrix({
|
||||||
rows,
|
rows,
|
||||||
onOpenPlaybook,
|
onOpenPlaybook,
|
||||||
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
rows: ReconActionRow[];
|
rows: ReconActionRow[];
|
||||||
onOpenPlaybook?: (lane: string) => void;
|
onOpenPlaybook?: (lane: string) => void;
|
||||||
|
onAction?: (lane: string, action: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -312,6 +317,7 @@ export const ActionMatrix = memo(function ActionMatrix({
|
|||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane);
|
if (a === 'playbook' && onOpenPlaybook) onOpenPlaybook(r.lane);
|
||||||
|
onAction?.(r.lane, a);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{a}
|
{a}
|
||||||
|
|||||||
@@ -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 { api } from '../../api/client';
|
||||||
import { HelpTip } from '../HelpTip';
|
import { HelpTip } from '../HelpTip';
|
||||||
import { loadReconHistory, openPortsSignature } from '../../help/reconHistory';
|
import { diffReconReports, loadReconHistory, openPortsSignature } from '../../help/reconHistory';
|
||||||
import type { ReconHistoryEntry } from '../../types/recon';
|
import type { ReconHistoryEntry } from '../../types/recon';
|
||||||
|
|
||||||
export default memo(function HistoryPanel({
|
export default memo(function HistoryPanel({
|
||||||
@@ -34,6 +34,16 @@ export default memo(function HistoryPanel({
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
const diffsByScanId = useMemo(() => {
|
||||||
|
const out = new Map<string, ReturnType<typeof diffReconReports>>();
|
||||||
|
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 (
|
return (
|
||||||
<section className="neon-card dr-history" data-testid="dr-history-panel">
|
<section className="neon-card dr-history" data-testid="dr-history-panel">
|
||||||
<div className="dr-fleet-head">
|
<div className="dr-fleet-head">
|
||||||
@@ -49,20 +59,32 @@ export default memo(function HistoryPanel({
|
|||||||
<p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p>
|
<p className="dr-empty">No prior scans{host.trim() ? ` for ${host.trim()}` : ''}.</p>
|
||||||
) : null}
|
) : null}
|
||||||
<ul className="dr-history-list">
|
<ul className="dr-history-list">
|
||||||
{entries.map((e) => (
|
{entries.map((e) => {
|
||||||
<li key={e.scan_id}>
|
const diffs = diffsByScanId.get(e.scan_id) ?? [];
|
||||||
<button
|
return (
|
||||||
type="button"
|
<li key={e.scan_id}>
|
||||||
className="dr-history-item"
|
<button
|
||||||
onClick={() => onSelect?.(e)}
|
type="button"
|
||||||
>
|
className="dr-history-item"
|
||||||
<span className="font-tech">{e.host}</span>
|
onClick={() => onSelect?.(e)}
|
||||||
<span className="dr-history-meta">
|
>
|
||||||
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
|
<span className="font-tech">{e.host}</span>
|
||||||
</span>
|
<span className="dr-history-meta">
|
||||||
</button>
|
{e.profile || 'scan'} · {new Date(e.scanned_at).toLocaleString()} · ports {openPortsSignature(e.report)}
|
||||||
</li>
|
</span>
|
||||||
))}
|
{diffs.length > 0 ? (
|
||||||
|
<span className="dr-history-diffs" data-testid={`dr-history-diff-${e.scan_id}`}>
|
||||||
|
{diffs.map((d) => (
|
||||||
|
<span key={`${d.kind}-${d.label}`} className={`dr-history-diff ${d.kind}`}>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,7 +148,22 @@ export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: s
|
|||||||
export type AdminSurfaceMapRow = ReconAdminSurfaceFinding;
|
export type AdminSurfaceMapRow = ReconAdminSurfaceFinding;
|
||||||
|
|
||||||
export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] {
|
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) {
|
export function buildTechStackHints(report: ReconScanReport) {
|
||||||
|
|||||||
@@ -420,6 +420,48 @@
|
|||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
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 {
|
.dr-fp-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ describe('DeployReconPage', () => {
|
|||||||
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
|
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('dr-scan-btn')).toBeDisabled();
|
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 () => {
|
it('runs scan and shows port matrix and findings', async () => {
|
||||||
@@ -117,8 +119,10 @@ describe('DeployReconPage', () => {
|
|||||||
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
|
||||||
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('dr-finding-ssrf')).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 });
|
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 () => {
|
it('shows fleet discoveries table with uninfected badge', async () => {
|
||||||
|
|||||||
@@ -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 { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { BuildRecord } from '../types';
|
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 { HelpTip } from '../components/HelpTip';
|
||||||
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
|
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
|
||||||
import {
|
import {
|
||||||
FLEET_SPREAD_PORTS,
|
ActionMatrix,
|
||||||
RECON_PORT_HINTS,
|
BannerGrabPanel,
|
||||||
|
FindingCard,
|
||||||
|
FormFingerprintCards,
|
||||||
|
PortBundleSelector,
|
||||||
|
ScanProfileSelector,
|
||||||
|
SsrfCanaryPanel,
|
||||||
|
StreamingPortMatrix,
|
||||||
|
TechStackBanner,
|
||||||
|
UploadAdminMap,
|
||||||
|
useDeployReconDerived,
|
||||||
|
} from '../components/Recon/DeployReconPanels';
|
||||||
|
import {
|
||||||
|
buildActionMatrix,
|
||||||
|
buildPlaybookTree,
|
||||||
buildReconFindingCards,
|
buildReconFindingCards,
|
||||||
buildReconMermaid,
|
buildReconMermaid,
|
||||||
crucibleSpreadLink,
|
crucibleSpreadLink,
|
||||||
curlInstallLine,
|
curlInstallLine,
|
||||||
|
FLEET_SPREAD_PORTS,
|
||||||
|
isLocalScanUnreachableError,
|
||||||
reconScanBody,
|
reconScanBody,
|
||||||
|
streamRevealPorts,
|
||||||
|
suggestDeployKitFinding,
|
||||||
|
type PortBundleId,
|
||||||
|
type ScanProfileId,
|
||||||
} from '../help/deployRecon';
|
} from '../help/deployRecon';
|
||||||
import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel';
|
import { exportReconJson, saveReconHistoryEntry } from '../help/reconHistory';
|
||||||
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import './DeployReconPage.css';
|
import './DeployReconPage.css';
|
||||||
import '../components/Fleet/ReconVisuals.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 SCAN_DEBOUNCE_MS = 350;
|
||||||
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 ScanSkeleton() {
|
function ScanSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -46,90 +55,35 @@ function ScanSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PortMatrix = memo(function PortMatrix({
|
|
||||||
ports,
|
|
||||||
onHint,
|
|
||||||
}: {
|
|
||||||
ports: ReconPortResult[];
|
|
||||||
onHint: (hint: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div data-testid="dr-port-matrix">
|
|
||||||
<h3 className="dr-section-title">
|
|
||||||
Port matrix <HelpTip field="dr_port_matrix" />
|
|
||||||
</h3>
|
|
||||||
<div className="dr-port-matrix">
|
|
||||||
{ports.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p.port}
|
|
||||||
type="button"
|
|
||||||
className={`dr-port-cell${p.open ? ' open' : ''}`}
|
|
||||||
disabled={!p.open}
|
|
||||||
title={p.open ? RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open` : `TCP ${p.port} closed`}
|
|
||||||
onClick={() => {
|
|
||||||
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{p.port}
|
|
||||||
{p.open ? ' ●' : ' ○'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const FindingCard = memo(function FindingCard({
|
|
||||||
card,
|
|
||||||
curlLine,
|
|
||||||
host,
|
|
||||||
fleetSpreadOpen,
|
|
||||||
}: {
|
|
||||||
card: ReconWebFindingCard;
|
|
||||||
curlLine: string;
|
|
||||||
host: string;
|
|
||||||
fleetSpreadOpen: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article className="dr-finding-card" data-testid={`dr-finding-${card.id}`}>
|
|
||||||
<div className="dr-finding-head">
|
|
||||||
<span className="dr-finding-title">{card.title}</span>
|
|
||||||
<span className={`dr-confidence ${card.confidence}`}>{card.confidence}</span>
|
|
||||||
{card.spread_lane ? (
|
|
||||||
<span className="join-lane-badge">{card.spread_lane}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<p className="dr-finding-detail">{card.detail}</p>
|
|
||||||
{card.mermaid ? <pre className="dr-mermaid">{card.mermaid}</pre> : null}
|
|
||||||
<div className="dr-deploy-panel">
|
|
||||||
<CopyChip text={curlLine} label="Copy curl install.sh" />
|
|
||||||
{card.probe_url ? (
|
|
||||||
<CopyChip text={card.probe_url} label="Copy SSRF probe URL" />
|
|
||||||
) : null}
|
|
||||||
{fleetSpreadOpen ? (
|
|
||||||
<Link to={crucibleSpreadLink(host)} className="btn btn-outline btn-sm">
|
|
||||||
Fleet spread to {host}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ResultsPanel = memo(function ResultsPanel({
|
const ResultsPanel = memo(function ResultsPanel({
|
||||||
report,
|
report,
|
||||||
cards,
|
|
||||||
mermaid,
|
|
||||||
serverBase,
|
serverBase,
|
||||||
pinnedBuildId,
|
pinnedBuildId,
|
||||||
|
deployKitLane,
|
||||||
|
streaming,
|
||||||
|
visiblePorts,
|
||||||
|
onOpenPlaybook,
|
||||||
|
onMatrixAction,
|
||||||
|
canaryWsHit,
|
||||||
}: {
|
}: {
|
||||||
report: ReconScanReport;
|
report: ReconScanReport;
|
||||||
cards: ReconWebFindingCard[];
|
|
||||||
mermaid: string;
|
|
||||||
serverBase: string;
|
serverBase: string;
|
||||||
pinnedBuildId: 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 [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(
|
const curlLine = useMemo(
|
||||||
() => curlInstallLine(serverBase, pinnedBuildId),
|
() => curlInstallLine(serverBase, pinnedBuildId),
|
||||||
[serverBase, pinnedBuildId],
|
[serverBase, pinnedBuildId],
|
||||||
@@ -141,9 +95,27 @@ const ResultsPanel = memo(function ResultsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dr-results" data-testid="dr-results">
|
<div className="dr-results" data-testid="dr-results">
|
||||||
<PortMatrix ports={report.ports} onHint={setPortHint} />
|
{deployKitLane ? (
|
||||||
|
<p className="dr-deploy-kit-hint" data-testid="dr-deploy-kit">
|
||||||
|
Deploy kit lane: <span className="font-tech">{deployKitLane}</span>
|
||||||
|
<HelpTip field="dr_deploy_kit" />
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{report.relay_via ? (
|
||||||
|
<p className="dr-relay-via">Relayed via {report.relay_via}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<StreamingPortMatrix ports={visiblePorts} streaming={streaming} onHint={setPortHint} />
|
||||||
{portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null}
|
{portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null}
|
||||||
|
|
||||||
|
<BannerGrabPanel banners={report.banners ?? []} />
|
||||||
|
<SsrfCanaryPanel canary={report.canary} scanId={report.scan_id} wsHitScanId={canaryWsHit} />
|
||||||
|
<FormFingerprintCards cards={derived.fingerprintCards} />
|
||||||
|
<UploadAdminMap rows={derived.adminRows} />
|
||||||
|
<TechStackBanner stack={derived.stack} />
|
||||||
|
|
||||||
|
<ActionMatrix rows={actionRows} onOpenPlaybook={onOpenPlaybook} onAction={onMatrixAction} />
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary className="dr-section-title">Web findings</summary>
|
<summary className="dr-section-title">Web findings</summary>
|
||||||
{cards.length === 0 ? (
|
{cards.length === 0 ? (
|
||||||
@@ -157,6 +129,7 @@ const ResultsPanel = memo(function ResultsPanel({
|
|||||||
curlLine={curlLine}
|
curlLine={curlLine}
|
||||||
host={report.host}
|
host={report.host}
|
||||||
fleetSpreadOpen={fleetSpreadOpen}
|
fleetSpreadOpen={fleetSpreadOpen}
|
||||||
|
deployKitLane={deployKitLane}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,35 +143,50 @@ const ResultsPanel = memo(function ResultsPanel({
|
|||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(report.recommendations?.length ?? 0) > 0 ? (
|
<div className="dr-scan-actions">
|
||||||
<details>
|
<button
|
||||||
<summary className="dr-section-title">Spread recommendations</summary>
|
type="button"
|
||||||
<ul className="dr-empty" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
className="btn btn-outline btn-sm"
|
||||||
{report.recommendations!.map((r, i) => (
|
data-testid="dr-export-json"
|
||||||
<li key={`${r.lane}-${r.template}-${i}`}>{r.reason}</li>
|
onClick={() => exportReconJson(report)}
|
||||||
))}
|
>
|
||||||
</ul>
|
Export JSON
|
||||||
</details>
|
</button>
|
||||||
) : null}
|
<Link to={crucibleSpreadLink(report.host, deployKitLane)} className="btn btn-outline btn-sm">
|
||||||
|
Open Crucible spread
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function DeployReconPage() {
|
export default function DeployReconPage() {
|
||||||
|
const { latestMessage } = useWebSocket();
|
||||||
const [host, setHost] = useState('');
|
const [host, setHost] = useState('');
|
||||||
const [port, setPort] = useState('80');
|
const [port, setPort] = useState('80');
|
||||||
const [https, setHttps] = useState(false);
|
const [https, setHttps] = useState(false);
|
||||||
const [pathPrefix, setPathPrefix] = useState('');
|
const [pathPrefix, setPathPrefix] = useState('');
|
||||||
|
const [scanProfile, setScanProfile] = useState<ScanProfileId>('quick');
|
||||||
|
const [portBundle, setPortBundle] = useState<PortBundleId>('fleet');
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [relayOffer, setRelayOffer] = useState(false);
|
||||||
|
const [relayBusy, setRelayBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ReconScanReport | null>(null);
|
const [report, setReport] = useState<ReconScanReport | null>(null);
|
||||||
|
const [visiblePorts, setVisiblePorts] = useState<ReconPortResult[]>([]);
|
||||||
const [manualScans, setManualScans] = useState<ReconScanReport[]>([]);
|
const [manualScans, setManualScans] = useState<ReconScanReport[]>([]);
|
||||||
const [deepScanIp, setDeepScanIp] = useState('');
|
const [deepScanIp, setDeepScanIp] = useState('');
|
||||||
const [serverBase, setServerBase] = useState('');
|
const [serverBase, setServerBase] = useState('');
|
||||||
const [pinnedBuildId, setPinnedBuildId] = useState('');
|
const [pinnedBuildId, setPinnedBuildId] = useState('');
|
||||||
|
const [deployKitLane, setDeployKitLane] = useState('');
|
||||||
|
const [playbookOpen, setPlaybookOpen] = useState(false);
|
||||||
|
const [playbookLane, setPlaybookLane] = useState<string | undefined>();
|
||||||
|
const [canaryWsHit, setCanaryWsHit] = useState<string | null>(null);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const streamAbortRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
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 runScan = useCallback(async (targetHost?: string) => {
|
||||||
const trimmed = (targetHost ?? host).trim();
|
const trimmed = (targetHost ?? host).trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -228,33 +254,54 @@ export default function DeployReconPage() {
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
setRelayOffer(false);
|
||||||
const portNum = parseInt(port, 10) || 80;
|
const portNum = parseInt(port, 10) || 80;
|
||||||
try {
|
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);
|
const res = await api.reconScan(body, controller.signal);
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) await applyReport(res);
|
||||||
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');
|
const msg = e instanceof Error ? e.message : 'Scan failed';
|
||||||
|
setError(msg);
|
||||||
|
if (isLocalScanUnreachableError(msg)) setRelayOffer(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
setDeepScanIp('');
|
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) => {
|
const handleScanDeeper = useCallback((ip: string) => {
|
||||||
setHost(ip);
|
setHost(ip);
|
||||||
setDeepScanIp(ip);
|
setDeepScanIp(ip);
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setReport(null);
|
||||||
|
setVisiblePorts([]);
|
||||||
|
streamAbortRef.current = true;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
void runScan(ip);
|
void runScan(ip);
|
||||||
@@ -268,9 +315,12 @@ export default function DeployReconPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
streamAbortRef.current = true;
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setRelayOffer(false);
|
||||||
setReport(null);
|
setReport(null);
|
||||||
|
setVisiblePorts([]);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
void runScan();
|
void runScan();
|
||||||
@@ -279,18 +329,37 @@ export default function DeployReconPage() {
|
|||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
streamAbortRef.current = true;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cards = useMemo(
|
const playbookTree = useMemo(
|
||||||
() => (report ? buildReconFindingCards(report, serverBase) : []),
|
() => (report ? buildPlaybookTree(report) : []),
|
||||||
[report, serverBase],
|
[report],
|
||||||
);
|
);
|
||||||
const mermaid = useMemo(
|
|
||||||
() => (report ? buildReconMermaid(report.ports, cards) : ''),
|
const curlLine = useMemo(
|
||||||
[report, cards],
|
() => 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 (
|
return (
|
||||||
<div className="page deploy-recon-page operator-deck-page">
|
<div className="page deploy-recon-page operator-deck-page">
|
||||||
<SacredPageHeader
|
<SacredPageHeader
|
||||||
@@ -301,6 +370,8 @@ export default function DeployReconPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="neon-card dr-scan-form" aria-label="Recon target">
|
<section className="neon-card dr-scan-form" aria-label="Recon target">
|
||||||
|
<ScanProfileSelector value={scanProfile} onChange={setScanProfile} />
|
||||||
|
<PortBundleSelector value={portBundle} onChange={setPortBundle} />
|
||||||
<div className="dr-field">
|
<div className="dr-field">
|
||||||
<label htmlFor="dr-host">Host / IP</label>
|
<label htmlFor="dr-host">Host / IP</label>
|
||||||
<input
|
<input
|
||||||
@@ -348,36 +419,82 @@ export default function DeployReconPage() {
|
|||||||
data-testid="dr-path-input"
|
data-testid="dr-path-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="dr-scan-actions">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-primary"
|
type="button"
|
||||||
disabled={scanning || !host.trim()}
|
className="btn btn-primary"
|
||||||
onClick={scheduleScan}
|
disabled={scanning || !host.trim()}
|
||||||
data-testid="dr-scan-btn"
|
onClick={scheduleScan}
|
||||||
>
|
data-testid="dr-scan-btn"
|
||||||
{scanning ? 'Scanning…' : 'Scan'}
|
>
|
||||||
</button>
|
{scanning ? 'Scanning…' : 'Scan'}
|
||||||
|
</button>
|
||||||
|
{relayOffer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
disabled={relayBusy}
|
||||||
|
onClick={() => void runRelayScan()}
|
||||||
|
data-testid="dr-relay-scan"
|
||||||
|
>
|
||||||
|
{relayBusy ? 'Relaying…' : 'Relay scan via fleet'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybookLane(undefined);
|
||||||
|
setPlaybookOpen(true);
|
||||||
|
}}
|
||||||
|
data-testid="dr-playbook-open"
|
||||||
|
>
|
||||||
|
Playbook wizard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error ? <p className="dr-error" role="alert">{error}</p> : null}
|
{error ? <p className="dr-error" role="alert">{error}</p> : null}
|
||||||
{scanning ? <ScanSkeleton /> : null}
|
{scanning && !report ? <ScanSkeleton /> : null}
|
||||||
{report && !scanning ? (
|
{report && !scanning ? (
|
||||||
<ResultsPanel
|
<ResultsPanel
|
||||||
report={report}
|
report={report}
|
||||||
cards={cards}
|
|
||||||
mermaid={mermaid}
|
|
||||||
serverBase={serverBase}
|
serverBase={serverBase}
|
||||||
pinnedBuildId={pinnedBuildId}
|
pinnedBuildId={pinnedBuildId}
|
||||||
|
deployKitLane={deployKitLane}
|
||||||
|
streaming={streaming}
|
||||||
|
visiblePorts={visiblePorts.length ? visiblePorts : report.ports}
|
||||||
|
onOpenPlaybook={(lane) => {
|
||||||
|
setPlaybookLane(lane);
|
||||||
|
setPlaybookOpen(true);
|
||||||
|
}}
|
||||||
|
onMatrixAction={handleMatrixAction}
|
||||||
|
canaryWsHit={canaryWsHit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FleetDiscoveriesPanel
|
<Suspense fallback={<p className="dr-empty">Loading history…</p>}>
|
||||||
manualScans={manualScans}
|
<HistoryPanel host={host} onSelect={handleHistorySelect} />
|
||||||
onScanDeeper={handleScanDeeper}
|
</Suspense>
|
||||||
scanningIp={deepScanIp}
|
|
||||||
serverBase={serverBase}
|
<Suspense fallback={null}>
|
||||||
pinnedBuildId={pinnedBuildId}
|
<FleetDiscoveriesPanel
|
||||||
/>
|
manualScans={manualScans}
|
||||||
|
onScanDeeper={handleScanDeeper}
|
||||||
|
scanningIp={deepScanIp}
|
||||||
|
serverBase={serverBase}
|
||||||
|
pinnedBuildId={pinnedBuildId}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PlaybookWizardModal
|
||||||
|
open={playbookOpen}
|
||||||
|
onClose={() => setPlaybookOpen(false)}
|
||||||
|
tree={playbookTree}
|
||||||
|
focusLane={playbookLane}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user