diff --git a/server/web/src/components/Recon/DeployReconPanels.tsx b/server/web/src/components/Recon/DeployReconPanels.tsx new file mode 100644 index 0000000..0da973a --- /dev/null +++ b/server/web/src/components/Recon/DeployReconPanels.tsx @@ -0,0 +1,383 @@ +import { memo, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client'; +import { HelpTip } from '../HelpTip'; +import { + FLEET_SPREAD_PORTS, + PORT_BUNDLES, + RECON_PORT_HINTS, + SCAN_PROFILES, + crucibleSpreadLink, + curlInstallLine, + type PortBundleId, + type ScanProfileId, +} from '../../help/deployRecon'; +import type { + ReconActionRow, + ReconFormFingerprintCard, +} from '../../help/deployRecon'; +import { + buildAdminSurfaceMap, + buildFormFingerprintCards, + buildTechStackHints, + type AdminSurfaceMapRow, +} from '../../help/deployRecon'; +import type { + ReconPortBanner, + ReconPortResult, + ReconScanReport, + ReconSsrfCanaryInfo, + ReconStackEntry, + ReconWebFindingCard, +} from '../../types/recon'; +import { ssrfCanaryToken } from '../../help/deployRecon'; + +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 ( + + ); +} + +export const ScanProfileSelector = memo(function ScanProfileSelector({ + value, + onChange, +}: { + value: ScanProfileId; + onChange: (v: ScanProfileId) => void; +}) { + return ( +
+ Scan profile +
+ {(Object.keys(SCAN_PROFILES) as ScanProfileId[]).map((id) => ( + + ))} +
+
+ ); +}); + +export const PortBundleSelector = memo(function PortBundleSelector({ + value, + onChange, +}: { + value: PortBundleId; + onChange: (v: PortBundleId) => void; +}) { + return ( +
+ Port bundle + +
+ ); +}); + +export const StreamingPortMatrix = memo(function StreamingPortMatrix({ + ports, + streaming, + onHint, +}: { + ports: ReconPortResult[]; + streaming?: boolean; + onHint: (hint: string) => void; +}) { + return ( +
+

+ Port matrix +

+
+ {ports.map((p) => ( + + ))} +
+
+ ); +}); + +export const BannerGrabPanel = memo(function BannerGrabPanel({ banners }: { banners: ReconPortBanner[] }) { + if (!banners?.length) return null; + return ( +
+

Banner grab

+ +
+ ); +}); + +export const FormFingerprintCards = memo(function FormFingerprintCards({ + cards, +}: { + cards: ReconFormFingerprintCard[]; +}) { + if (cards.length === 0) return null; + return ( +
+

Form fingerprints

+
+ {cards.map((c) => ( +
+
+ {c.name} + score {c.score} +
+

{c.page_url}

+ {c.matches.length ?

{c.matches.join(' · ')}

: null} + +
+ ))} +
+
+ ); +}); + +export const SsrfCanaryPanel = memo(function SsrfCanaryPanel({ + canary, + scanId, + wsHitScanId, +}: { + canary?: ReconSsrfCanaryInfo; + scanId?: string; + wsHitScanId?: string | null; +}) { + const token = ssrfCanaryToken(canary?.scan_id || scanId || ''); + const [status, setStatus] = useState(canary?.status ?? 'pending'); + const [hitAt, setHitAt] = useState(canary?.hit_at); + + useEffect(() => { + if (canary?.status) setStatus(canary.status); + if (canary?.hit_at) setHitAt(canary.hit_at); + }, [canary]); + + useEffect(() => { + if (wsHitScanId && token && wsHitScanId === token) { + setStatus('confirmed'); + setHitAt(new Date().toISOString()); + } + }, [wsHitScanId, token]); + + useEffect(() => { + if (!token || status === 'confirmed') return undefined; + const timer = setInterval(() => { + void api.getSsrfCanaryStatus(token).then((info) => { + if (!info) return; + setStatus(info.status); + if (info.hit_at) setHitAt(info.hit_at); + }); + }, 4000); + return () => clearInterval(timer); + }, [token, status]); + + const url = canary?.url; + if (!url && !token) return null; + + return ( +
+

+ SSRF canary + {status} +

+ {url ? : null} + {hitAt ?

Hit at {hitAt}

: null} + {canary?.paste_field_name ? ( +

Paste into field: {canary.paste_field_name}

+ ) : null} +
+ ); +}); + +export const UploadAdminMap = memo(function UploadAdminMap({ rows }: { rows: AdminSurfaceMapRow[] }) { + if (!rows.length) return null; + return ( +
+

Upload / admin map

+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
PathStatusSignal
+ + {r.path} + + {r.status_code}{r.signal}
+
+ ); +}); + +export const TechStackBanner = memo(function TechStackBanner({ stack }: { stack: ReconStackEntry[] }) { + if (!stack.length) return null; + return ( +
+

Tech stack

+ +
+ ); +}); + +export const ActionMatrix = memo(function ActionMatrix({ + rows, + onOpenPlaybook, +}: { + rows: ReconActionRow[]; + onOpenPlaybook?: (lane: string) => void; +}) { + if (!rows.length) return null; + return ( +
+

Action matrix

+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
LaneReasonActions
{r.title}{r.reason} +
+ {r.actions.map((a) => ( + + ))} +
+
+
+ ); +}); + +export const FindingCard = memo(function FindingCard({ + card, + curlLine, + host, + fleetSpreadOpen, + deployKitLane, +}: { + card: ReconWebFindingCard; + curlLine: string; + host: string; + fleetSpreadOpen: boolean; + deployKitLane?: string; +}) { + const finding = deployKitLane || card.spread_lane; + 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} +
+
+ ); +}); + +export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin: string) { + return useMemo(() => { + if (!report) { + return { + fingerprintCards: [] as ReconFormFingerprintCard[], + adminRows: [] as AdminSurfaceMapRow[], + stack: [] as ReconStackEntry[], + }; + } + return { + fingerprintCards: buildFormFingerprintCards(report, deckOrigin), + adminRows: buildAdminSurfaceMap(report), + stack: buildTechStackHints(report), + }; + }, [report, deckOrigin]); +} + diff --git a/server/web/src/components/Recon/HistoryPanel.tsx b/server/web/src/components/Recon/HistoryPanel.tsx new file mode 100644 index 0000000..8584e66 --- /dev/null +++ b/server/web/src/components/Recon/HistoryPanel.tsx @@ -0,0 +1,69 @@ +import { memo, useCallback, useEffect, useState } from 'react'; +import { api } from '../../api/client'; +import { HelpTip } from '../HelpTip'; +import { loadReconHistory, openPortsSignature } from '../../help/reconHistory'; +import type { ReconHistoryEntry } from '../../types/recon'; + +export default memo(function HistoryPanel({ + host, + onSelect, +}: { + host: string; + onSelect?: (entry: ReconHistoryEntry) => void; +}) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const server = await api.getReconHistory(); + const local = await loadReconHistory(host.trim() || undefined); + const map = new Map(); + for (const e of [...(server?.entries ?? []), ...local]) { + if (host.trim() && e.host.trim() !== host.trim()) continue; + map.set(e.scan_id, e); + } + setEntries([...map.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at))); + } finally { + setLoading(false); + } + }, [host]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return ( +
+
+

+ Scan history +

+ +
+ {loading ?

Loading history…

: null} + {!loading && entries.length === 0 ? ( +

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

+ ) : null} +
    + {entries.map((e) => ( +
  • + +
  • + ))} +
+
+ ); +}); diff --git a/server/web/src/components/Recon/PlaybookWizardModal.tsx b/server/web/src/components/Recon/PlaybookWizardModal.tsx new file mode 100644 index 0000000..1bfbb27 --- /dev/null +++ b/server/web/src/components/Recon/PlaybookWizardModal.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import { HelpTip } from '../HelpTip'; +import type { PlaybookTreeNode } from '../../help/deployRecon'; + +export default memo(function PlaybookWizardModal({ + open, + onClose, + tree, + focusLane, +}: { + open: boolean; + onClose: () => void; + tree: PlaybookTreeNode[]; + focusLane?: string; +}) { + if (!open) return null; + const root = tree[0]; + return ( +
+
+
+

Playbook wizard

+ +
+ {root ? ( +
+

+ Target {root.label} +

+ +
+ ) : ( +

Run a scan to populate spread lanes.

+ )} +
+
+ ); +}); diff --git a/server/web/src/help/reconHistory.test.ts b/server/web/src/help/reconHistory.test.ts new file mode 100644 index 0000000..37ba9de --- /dev/null +++ b/server/web/src/help/reconHistory.test.ts @@ -0,0 +1,43 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + diffReconReports, + exportReconJson, + openPortsSignature, + saveReconHistoryEntry, +} from './reconHistory'; +import type { ReconScanReport } from '../types/recon'; + +const base: ReconScanReport = { + host: '10.0.0.5', + scan_id: 's1', + scanned_at: '2026-06-07T10:00:00Z', + ports: [{ port: 80, open: true }], +}; + +describe('reconHistory', () => { + afterEach(() => localStorage.clear()); + + it('saves and signs open ports', () => { + const entry = saveReconHistoryEntry(base); + expect(entry.scan_id).toBe('s1'); + expect(openPortsSignature(base)).toBe('80'); + }); + + it('diffs port changes', () => { + const next = { ...base, ports: [{ port: 80, open: true }, { port: 443, open: true }] }; + const diffs = diffReconReports(base, next); + expect(diffs.some((d) => d.kind === 'port_opened' && d.label.includes('443'))).toBe(true); + }); + + it('exports JSON without throwing', () => { + const click = vi.fn(); + const create = vi.spyOn(document, 'createElement').mockReturnValue({ click } as unknown as HTMLAnchorElement); + exportReconJson(base, 'out.json'); + expect(click).toHaveBeenCalled(); + create.mockRestore(); + }); +}); + diff --git a/server/web/src/help/reconHistory.ts b/server/web/src/help/reconHistory.ts new file mode 100644 index 0000000..6716eef --- /dev/null +++ b/server/web/src/help/reconHistory.ts @@ -0,0 +1,131 @@ +import type { ReconHistoryEntry, ReconScanReport } from '../types/recon'; + +const LOCAL_KEY = 'aetherforge.recon.history.v1'; +const LOCAL_LIMIT = 64; + +export type ReconReportDiffKind = + | 'port_opened' + | 'port_closed' + | 'ssrf_up' + | 'ssrf_down' + | 'cms_added'; + +export interface ReconReportDiff { + kind: ReconReportDiffKind; + label: string; + detail?: string; +} + +function normalizeEntry(raw: ReconHistoryEntry & { id?: string }): ReconHistoryEntry { + const scan_id = raw.scan_id || raw.id || `${raw.host}-${raw.scanned_at}`; + return { ...raw, scan_id, report: raw.report }; +} + +function readLocalEntries(): ReconHistoryEntry[] { + try { + const raw = localStorage.getItem(LOCAL_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as { entries?: (ReconHistoryEntry & { id?: string })[] }; + if (!Array.isArray(parsed.entries)) return []; + return parsed.entries.map(normalizeEntry); + } catch { + return []; + } +} + +function writeLocalEntries(entries: ReconHistoryEntry[]): void { + try { + localStorage.setItem(LOCAL_KEY, JSON.stringify({ entries: entries.slice(0, LOCAL_LIMIT) })); + } catch { + /* quota / private mode */ + } +} + +export function openPortsSignature(report: ReconScanReport): string { + return report.ports + .filter((p) => p.open) + .map((p) => p.port) + .sort((a, b) => a - b) + .join(','); +} + +export async function loadReconHistory(host?: string): Promise { + const local = readLocalEntries(); + if (!host?.trim()) { + return local; + } + const trimmed = host.trim(); + try { + const res = await fetch(`/api/v1/recon/history?host=${encodeURIComponent(trimmed)}`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + }); + if (res.ok) { + const body = (await res.json()) as { entries?: ReconHistoryEntry[] } | ReconHistoryEntry[]; + const server = Array.isArray(body) ? body : body.entries ?? []; + if (server.length > 0) { + const byId = new Map(); + for (const e of [...local, ...server.map(normalizeEntry)]) { + if (e.host.trim() === trimmed) byId.set(e.scan_id, e); + } + return [...byId.values()].sort((a, b) => b.scanned_at.localeCompare(a.scanned_at)); + } + } + } catch { + /* offline */ + } + return local.filter((e) => e.host.trim() === trimmed); +} + +export function saveReconHistoryEntry(report: ReconScanReport): ReconHistoryEntry { + const scan_id = report.scan_id ?? `${report.host}-${report.scanned_at}`; + const entry: ReconHistoryEntry = { + scan_id, + host: report.host, + scanned_at: report.scanned_at, + profile: report.profile, + status: report.status, + report, + }; + const prev = readLocalEntries().filter((e) => e.scan_id !== entry.scan_id); + writeLocalEntries([entry, ...prev]); + return entry; +} + +export function exportReconJson(report: ReconScanReport, filename?: string): void { + const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename ?? `recon-${report.host.replace(/[^a-zA-Z0-9._-]+/g, '-')}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +export function diffReconReports(prev: ReconScanReport, next: ReconScanReport): ReconReportDiff[] { + const diffs: ReconReportDiff[] = []; + const prevPorts = new Map(prev.ports.map((p) => [p.port, p.open])); + for (const p of next.ports) { + const was = prevPorts.get(p.port); + if (was !== true && p.open) { + diffs.push({ kind: 'port_opened', label: `TCP ${p.port} opened`, detail: 'Was closed on prior scan.' }); + } else if (was === true && !p.open) { + diffs.push({ kind: 'port_closed', label: `TCP ${p.port} closed`, detail: 'Was open on prior scan.' }); + } + } + const prevScore = prev.crawl?.ssrf_score ?? 0; + const nextScore = next.crawl?.ssrf_score ?? 0; + if (nextScore > prevScore) { + diffs.push({ kind: 'ssrf_up', label: 'SSRF score increased', detail: `${prevScore} → ${nextScore}` }); + } else if (nextScore < prevScore) { + diffs.push({ kind: 'ssrf_down', label: 'SSRF score decreased', detail: `${prevScore} → ${nextScore}` }); + } + const prevCms = new Set(prev.crawl?.cms_fingerprints ?? []); + for (const cms of next.crawl?.cms_fingerprints ?? []) { + if (!prevCms.has(cms)) { + diffs.push({ kind: 'cms_added', label: `CMS hint: ${cms}`, detail: 'New fingerprint vs prior scan.' }); + } + } + return diffs; +} +